Compare commits

..

11 Commits

Author SHA1 Message Date
nityanandagohain
301d496092 chore: add normalize for QueryRangeRequest 2026-06-05 12:31:19 +05:30
Aditya Singh
81eadac3a8 feat(trace-details): lazy aggregations + waterfall v4 cutover (#11556)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: wire available colors on spans instead of aggregations from waterfall api

* feat: integrate aggregation api

* feat: integrate v4 waterfall

* feat: dropdown style fix

* feat: move to open api spec

* feat: move to open api spec

* feat: minor changes
2026-06-04 20:09:44 +00:00
Aditya Singh
eec1c45e3f fix(trace-flamegraph): prevent layout hang on very wide traces (#11578)
* feat: algo change

* feat: update test
2026-06-04 20:02:13 +00:00
swapnil-signoz
ce26458b9f feat(cloud-integrations): adding endpoint services metadata for account (#11563)
* feat: adding endpoint services metadata for account

* fix: adding missing response writer in error

* refactor(cloud-integrations): use account-scoped  ListAccountServices endpoint when an account is connected (#11569)

* fix: frontend changes for issue 4616

* fix: update frontend changes

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-04 18:07:01 +00:00
swapnil-signoz
7932917f5f refactor: service response changes and dashboardID handling (#11485)
* refactor: service response changes and dashboardID handling

* refactor: removing unused enrichDashboardIDs func

* revert: restore frontend files to main state

* refactor: updating response

* refactor: updating nullable tag

* chore: adding required tags

* chore: adding required tags for ServiceDashboard struct

* refactor(integrations): align service-details UI with new cloud-integration API + disabled-dashboard state (#11547)

* refactor: cloud integration service

* fix: update schemas

* fix: update schema

* fix: minor fixes

* refactor: review comments

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-04 17:32:56 +00:00
Vinicius Lourenço
4cf8125372 refactor(sentry-config): disable tracing & ensure correct environment (#11552)
* refactor(sentry-config): disable tracing & ensure correct environment

* refactor(sentry-config): derive environment from build-time env var

Replace the runtime hostname-based getCurrentEnvironment() with a
build-time process.env.ENVIRONMENT value. The environment is injected
once at build time via VITE_ENVIRONMENT, wired through the vite define
block, and consumed directly by Sentry.init.

Staging builds bake in 'staging' and enterprise builds bake in
'production'.

* refactor(sentry-config): keep browserTracingIntegration for transaction tag

Tracing stays disabled (tracesSampleRate: 0), but the integration is
retained so the transaction tag remains available for routing.
Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055

* feat(sentry-config): set Sentry release from VITE_VERSION

Set release in Sentry.init from process.env.VERSION (VITE_VERSION, default 'dev') and pin the @sentry/vite-plugin upload release to the same value so sourcemaps resolve. Plumb VITE_VERSION through build-enterprise & build-staging (make info-version) and gor-signoz (tag).

* fix(sentry-config): align Sentry.init indentation and set VITE_ENVIRONMENT for gor-signoz

* fix(sentry-config): require VITE_VERSION for sourcemap upload

Refuse to register the Sentry vite plugin without an explicit VITE_VERSION, and drop the 'dev' fallback for both the plugin release name and process.env.VERSION so a sourcemap upload can never happen without a matching release.

---------

Co-authored-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-04 15:30:44 +00:00
Vinicius Lourenço
959e32b6f3 chore(codeowners): move openapi schema generation ownership to backend (#11585) 2026-06-04 15:00:10 +00:00
SagarRajput-7
d3304af0ed chore: removed workspace suspended page's text for data retention (#11501)
* feat: removed workspace locked and suspended page's text for data retention

* chore: reverted changes to workspacelocked file
2026-06-04 14:53:10 +00:00
Naman Verma
df7ef3cdb5 feat: v2 dashboard update, lock, unlock, and patch API (#11481)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: follow proper unmarshal json method structure

* feat: v2 create dashboard API

* fix: only return name of a tag in dashboard response

* fix: use existing tag's casing if new tag is a prefix of an existing tag

* fix: go lint fix

* fix: more dashboard request validations

* chore: separate method for validation

* fix: module should also validate postable dashboard

* test: integration tests for create API

* test: integration test fixes

* chore: use existing mapper

* fix: remove extra spec from builder query marshalling

* fix: merge conflicts

* feat: v2 dashboard GET API

* feat: v2 dashboard update API

* fix: add allowed values in err messages

* fix: remove extra (un)marshal cycle

* fix: return 500 err if spec is nil for composite kind w/ code comment

* fix: no need for copying textboxvariablespec

* fix: wrap errors

* chore: no v2 subpackage

* fix: no v2 package and its consequences

* fix: no v2 package and its consequences

* Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get

* fix: merge fixes

* fix: query-less panels not allowed

* feat: consolidate tag module and tagtypes changes from downstream branches

* chore: update api specs

* chore: update api specs

* fix: allow only 1 query in a panel

* test: unit test fixes

* feat: method to fetch tags for multiple entries at once

* test: fix mock interface in test

* feat: move tags to key:value pairs model

* feat: entity type column in tags

* fix: pass entity type in create many

* feat: reserved DSL key validation for tags

* feat: new module for tags

* chore: merge conflicts error fixing pt 1

* fix: lint fix regarding nil, nil return in test file

* chore: change where tag module is instantiated

* fix: add back api endpoint

* chore: generate api spec

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: extend bun in joinedRow

* fix: compile error fix

* fix: remove soft delete references

* feat: method to build postable tags from tags

* fix: diff error codes for invalid keys and values

* fix: correct pk in bun model for tag relations

* fix: created and updated by schema

* fix: use coretypes.Kind instead of defining entity type

* fix: singular table name

* chore: remove org ID from tag relation

* feat: foreign key on tag id

* feat: add SyncTags method that covers creation and linking

* fix: remove entity type definition

* fix: fix build errors in dashboard module

* chore: bump migration number

* chore: change entity id to resource id

* fix: add org id filter in all list and delete queries

* fix: remove user auditable

* fix: add ID in tag relation

* fix: fix build error

* fix: fix build error

* chore: bump migration number

* fix: add len check on tags keys and values

* fix: add regex for tags

* chore: remove methods that shouldn't be exposed

* fix: use sync tags in create api

* feat: functional unique index in sql schema

* fix: only ascii in regex

* fix: use sync tags method in update

* chore: rename create method to createOrGet

* chore: use tagtypestest package for mock store

* chore: combine functional unique index with unique index

* chore: move tag resolution to module

* test: add unit tests for new idx type

* chore: comment out tags unique index for now

* chore: add a todo comment

* chore: comment out unique index test

* feat: add created at to tag relations

* chore: comment out unique index test

* chore: bump migration number

* chore: remove uploaded grafana flag from metadata

* Merge branch 'main' into nv/v2-dashboard-create

* chore: revert idx generation to resolve conflicts

* fix: use store.RunInTx instead of taking in sqlstore

* fix: use binding package to get request

* chore: move NewDashboardV2 to NewDashboardV2WithoutTags

* chore: rename module to m

* fix: add ctx needed in sqlstore

* fix: remove sqlstore passage in ee pkg

* chore: change dashboardData to dashboardSpec

* feat: follow the metadata+spec key structure

* feat: follow the metadata+spec key structure in open api spec

* feat: v2 dashboard GET API (#11136)

* feat: v2 dashboard GET API

* Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get

* chore: update api specs

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: fix build error

* chore: revert all frontend changes

* fix: remove public dashboard from get v2 call

* chore: revert all frontend changes

* fix: fix build errors post merge conflict resolution

* feat: lock, unlock, create public, update public v2 dashboard APIs (#11167)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* chore: update api specs

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* chore: revert all frontend changes

* fix: fix build errors and remove v2 create/update public apis

* chore: use v1 methods wherever possible

* fix: use update v2 store method

* chore: update frontend schema

* chore: update frontend schema

* chore: generate api specs

* chore: generate api specs

* feat: patch dashboard api (#11182)

* feat: lock, unlock, create public, update public v2 dashboard APIs

* feat: delete dashboard v2 API and hard delete cron job

* feat: patch dashboard api

* chore: update api specs

* chore: update api specs

* chore: update api specs

* chore: remove delete related work

* fix: add examples of structs for value param in param description

* test: unit test fixes

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* test: key value tags in test

* fix: build error in patch module method

* fix: build error in Apply method

* fix: use sync tags method in update

* fix: fix build errors

* fix: fix all patch application tests

* chore: add more mapper methods

* fix: add source for v2 dashboards

* chore: incorporate source

* chore: incorporate source in api spec

* chore: incorporate source

* fix: add some required fields

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2

* feat: add immutable name in dashboard v2 api specs

* fix: remove unused param in constructor

* fix: improve api descriptions

* fix: remove unneeded comment

* chore: increase MaxTagsPerDashboard to 10

* fix: set display name in unmarshal json

* chore: remove integration test for now (will add along with list api)

* feat: add validation on dashboard name

* test: fix build errors and tests based on name related changes

* fix: correct convertor method name

* test: add unit tests for type conversions

* chore: remove enum def of threshold comparison operator

* feat: add flag to generate unique name in backend

* chore: generate api specs

* chore: make tags required in postable

* fix: build error fix

* fix: fix build error in test after merge conflict

* fix: remove unused store method

* fix: remove unused module methods

* fix: use v1 store update method

* fix: change data to spec in api param description

* chore: add back accidentally removed tests

* fix: address review comments

* chore: generate frontend api spec

* chore: use same jsonpatch package as done in zeus

* chore: remove JSONPatchDocument and use patchable everywhere

* fix: make remove idempotent in patch

* chore: separate file for patch types

* chore: better error passage

* fix: remove extra decodePatch calls

* fix: use must new org id

* fix: proper error passage

* chore: rename updateable to updatable

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-06-04 11:59:40 +00:00
Gaurav Tewari
f4ede36d3e fix: ui issues in Trace and logs (#11564)
* fix: ui migration issues

* fix: minor issue

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-04 07:31:39 +00:00
Gaurav Tewari
fa7d941266 fix: time not being updated issue in domainlist and traces (#11460)
* fix: time not being updated issue in domainlist and traces

* refactor: make a hook

* chore: more refactor

* fix: test cases

* fix: test cases

* refactor: code

* fix: add test cases

* fix: test cases

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-06-04 07:31:14 +00:00
79 changed files with 3803 additions and 1911 deletions

4
.github/CODEOWNERS vendored
View File

@@ -188,3 +188,7 @@ go.mod @therealpandey
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -69,6 +69,8 @@ jobs:
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@@ -70,6 +70,8 @@ jobs:
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@@ -35,6 +35,8 @@ jobs:
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
echo 'VITE_ENVIRONMENT="production"' >> .env
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
- name: node-setup
uses: actions/setup-node@v5
with:

View File

@@ -870,14 +870,6 @@ components:
- timestampMillis
- data
type: object
CloudintegrationtypesAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
nullable: true
type: array
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
deploymentRegion:
@@ -1025,17 +1017,6 @@ components:
- ingestionUrl
- ingestionKey
type: object
CloudintegrationtypesDashboard:
properties:
definition:
$ref: '#/components/schemas/DashboardtypesStorableDashboardData'
description:
type: string
id:
type: string
title:
type: string
type: object
CloudintegrationtypesDataCollected:
properties:
logs:
@@ -1209,7 +1190,7 @@ components:
CloudintegrationtypesService:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesAssets'
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
@@ -1222,8 +1203,6 @@ components:
type: string
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
title:
type: string
required:
@@ -1234,9 +1213,17 @@ components:
- assets
- supportedSignals
- dataCollected
- telemetryCollectionStrategy
- cloudIntegrationService
type: object
CloudintegrationtypesServiceAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesServiceDashboard'
type: array
required:
- dashboards
type: object
CloudintegrationtypesServiceConfig:
properties:
aws:
@@ -1244,6 +1231,18 @@ components:
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
description:
type: string
integrationDashboard:
$ref: '#/components/schemas/CloudintegrationtypesStorableIntegrationDashboard'
title:
type: string
required:
- title
- description
type: object
CloudintegrationtypesServiceID:
enum:
- alb
@@ -1261,7 +1260,6 @@ components:
- sqs
- storageaccountsblob
- cdnprofile
- containerapp
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1279,6 +1277,30 @@ components:
- icon
- enabled
type: object
CloudintegrationtypesStorableIntegrationDashboard:
properties:
createdAt:
format: date-time
type: string
dashboardId:
type: string
id:
type: string
provider:
type: string
slug:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- dashboardId
- provider
- slug
- createdAt
- updatedAt
type: object
CloudintegrationtypesSupportedSignals:
properties:
logs:
@@ -1286,13 +1308,6 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -2646,6 +2661,30 @@ components:
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
type: object
DashboardtypesJSONPatchOperation:
properties:
from:
description: Source JSON Pointer for move/copy ops; ignored for other ops.
type: string
op:
$ref: '#/components/schemas/DashboardtypesPatchOp'
path:
description: JSON Pointer (RFC 6901) into the dashboard's postable shape
— e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0,
/tags/-.
type: string
value:
description: 'Value to add/replace/test against. The expected type depends
on the path. Common shapes (see referenced schemas for the exact field
set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N
(or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable;
/spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a
TagtypesPostableTag; /spec/display/name and other leaf string fields take
a string. Required for add/replace/test; ignored for remove/move/copy.'
required:
- op
- path
type: object
DashboardtypesLayout:
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
@@ -2861,6 +2900,20 @@ components:
$ref: '#/components/schemas/DashboardtypesQuery'
type: array
type: object
DashboardtypesPatchOp:
enum:
- add
- remove
- replace
- move
- copy
- test
type: string
DashboardtypesPatchableDashboardV2:
items:
$ref: '#/components/schemas/DashboardtypesJSONPatchOperation'
nullable: true
type: array
DashboardtypesPieChartPanelSpec:
properties:
formatting:
@@ -3148,6 +3201,27 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:
type: string
name:
type: string
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
nullable: true
type: array
required:
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesUpdatablePublicDashboard:
properties:
defaultTimeRange:
@@ -8029,6 +8103,64 @@ paths:
summary: Update account
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services:
get:
deprecated: false
description: This endpoint lists the services metadata for the specified account
and cloud provider
operationId: ListAccountServicesMetadata
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesGettableServicesMetadata'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List account services metadata
tags:
- cloudintegration
/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}:
put:
deprecated: false
@@ -12825,6 +12957,12 @@ paths:
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -12877,6 +13015,12 @@ paths:
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -12889,6 +13033,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -12903,6 +13053,262 @@ paths:
summary: Get dashboard (v2)
tags:
- dashboard
patch:
deprecated: false
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.'
operationId: PatchDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPatchableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Patch dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint updates a v2-shape dashboard's metadata, data, and
tag set. Locked dashboards are rejected.
operationId: UpdateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesUpdatableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/lock:
delete:
deprecated: false
description: This endpoint unlocks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: UnlockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Unlock dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint locks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: LockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -355,26 +355,32 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
var integrationService *cloudintegrationtypes.CloudIntegrationService
if !cloudIntegrationID.IsZero() {
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService != nil {
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
}
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
return nil, err
}
if cloudIntegrationID.IsZero() {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
}
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if storedService == nil {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
}
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
if err != nil {
return nil, err
}
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
if err != nil {
return nil, err
}
return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
@@ -583,20 +589,3 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
}
return nil
}
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
// TODO: remove this hack and send idiomatic response to client.
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
for i, d := range serviceDefinition.Assets.Dashboards {
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
continue
}
return err
}
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
}
return nil
}

View File

@@ -221,6 +221,18 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -351,19 +351,18 @@ function App(): JSX.Element {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: 'production',
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
integrations: [
// Kept for the `transaction` tag used in routing, even though
// tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: [],
// Session Replay
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event) {

View File

@@ -36,6 +36,8 @@ import type {
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccountServicesMetadata200,
ListAccountServicesMetadataPathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
@@ -631,6 +633,116 @@ export const useUpdateAccount = <
> => {
return useMutation(getUpdateAccountMutationOptions(options));
};
/**
* This endpoint lists the services metadata for the specified account and cloud provider
* @summary List account services metadata
*/
export const listAccountServicesMetadata = (
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListAccountServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
method: 'GET',
signal,
});
};
export const getListAccountServicesMetadataQueryKey = ({
cloudProvider,
id,
}: ListAccountServicesMetadataPathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
] as const;
};
export const getListAccountServicesMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListAccountServicesMetadataQueryKey({ cloudProvider, id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listAccountServicesMetadata>>
> = ({ signal }) => listAccountServicesMetadata({ cloudProvider, id }, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListAccountServicesMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof listAccountServicesMetadata>>
>;
export type ListAccountServicesMetadataQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List account services metadata
*/
export function useListAccountServicesMetadata<
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListAccountServicesMetadataQueryOptions(
{ cloudProvider, id },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List account services metadata
*/
export const invalidateListAccountServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListAccountServicesMetadataQueryKey({ cloudProvider, id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service

View File

@@ -21,8 +21,10 @@ import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
@@ -33,7 +35,13 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -816,3 +824,360 @@ export const invalidateGetDashboardV2 = async (
return queryClient;
};
/**
* 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.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesPatchableDashboardV2DTONull?: BodyType<DashboardtypesPatchableDashboardV2DTO | null> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPatchableDashboardV2DTONull,
signal,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
| BodyType<DashboardtypesPatchableDashboardV2DTO | null>
| undefined;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
return useMutation(getPatchDashboardV2MutationOptions(options));
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesUpdatableDashboardV2DTO?: BodyType<DashboardtypesUpdatableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesUpdatableDashboardV2DTO,
signal,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
| BodyType<DashboardtypesUpdatableDashboardV2DTO>
| undefined;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnlockDashboardV2MutationOptions(options));
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};

View File

@@ -2457,33 +2457,6 @@ export interface CloudintegrationtypesAccountDTO {
updatedAt?: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesDashboardDTO {
definition?: DashboardtypesStorableDashboardDataDTO;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
id?: string;
/**
* @type string
*/
title?: string;
}
export interface CloudintegrationtypesAssetsDTO {
/**
* @type array,null
*/
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
/**
* @type string
@@ -2574,7 +2547,6 @@ export enum CloudintegrationtypesServiceIDDTO {
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
containerapp = 'containerapp',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -2867,6 +2839,54 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
providerAccountId?: string;
}
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
dashboardId: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
provider: string;
/**
* @type string
*/
slug: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface CloudintegrationtypesServiceDashboardDTO {
/**
* @type string
*/
description: string;
integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO;
/**
* @type string
*/
title: string;
}
export interface CloudintegrationtypesServiceAssetsDTO {
/**
* @type array
*/
dashboards: CloudintegrationtypesServiceDashboardDTO[];
}
export interface CloudintegrationtypesSupportedSignalsDTO {
/**
* @type boolean
@@ -2878,13 +2898,8 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesAssetsDTO;
assets: CloudintegrationtypesServiceAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
@@ -2900,7 +2915,6 @@ export interface CloudintegrationtypesServiceDTO {
*/
overview: string;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
/**
* @type string
*/
@@ -3706,6 +3720,10 @@ export interface DashboardtypesCustomVariableSpecDTO {
customValue: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
[key: string]: unknown;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
@@ -4654,6 +4672,32 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
replace = 'replace',
move = 'move',
copy = 'copy',
test = 'test',
}
export interface DashboardtypesJSONPatchOperationDTO {
/**
* @type string
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
*/
from?: string;
op: DashboardtypesPatchOpDTO;
/**
* @type string
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
*/
path: string;
/**
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
*/
value?: unknown;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4663,6 +4707,13 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
}
/**
* @nullable
*/
export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -4706,6 +4757,26 @@ export enum DashboardtypesQueryPluginKindDTO {
'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL',
'signoz/TraceOperator' = 'signoz/TraceOperator',
}
export interface DashboardtypesUpdatableDashboardV2DTO {
/**
* @type string
*/
image?: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
schemaVersion: string;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesUpdatablePublicDashboardDTO {
/**
* @type string
@@ -8624,6 +8695,18 @@ export type UpdateAccountPathParameters = {
cloudProvider: string;
id: string;
};
export type ListAccountServicesMetadataPathParameters = {
cloudProvider: string;
id: string;
};
export type ListAccountServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
* @type string
*/
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
@@ -9477,6 +9560,34 @@ export type GetDashboardV2200 = {
status: string;
};
export type PatchDashboardV2PathParameters = {
id: string;
};
export type PatchDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UpdateDashboardV2PathParameters = {
id: string;
};
export type UpdateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};
export type LockDashboardV2PathParameters = {
id: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -1,15 +1,14 @@
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
const getTraceV4 = async (
props: GetTraceV4PayloadProps,
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
@@ -19,31 +18,37 @@ const getTraceV3 = async (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
const response = await getWaterfallV4(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
uncollapsedSpans,
limit: 10000,
},
);
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Generated client unwraps the axios response; .data is the waterfall payload.
// Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below.
type WireSpan = Omit<SpanV3, 'timestamp' | 'service.name'> & {
time_unix: number;
};
const rawPayload = response.data as unknown as Omit<
GetTraceV4SuccessResponse,
'spans'
> & { spans: WireSpan[] | null };
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
@@ -70,4 +75,4 @@ const getTraceV3 = async (
};
};
export default getTraceV3;
export default getTraceV4;

View File

@@ -33,7 +33,8 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -8,16 +8,16 @@ import { Tabs } from '@signozhq/ui/tabs';
import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import {
getListServicesMetadataQueryKey,
getListAccountServicesMetadataQueryKey,
invalidateGetService,
invalidateListServicesMetadata,
invalidateListAccountServicesMetadata,
useGetService,
useUpdateService,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesServiceConfigDTO,
CloudintegrationtypesServiceDTO,
ListServicesMetadata200,
ListAccountServicesMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
@@ -240,16 +240,12 @@ function ServiceDetails({
// instead of waiting for the refetch to complete.
reset(nextFormValues);
const servicesListQueryKey = getListServicesMetadataQueryKey(
{
cloudProvider: type,
},
{
cloud_integration_id: cloudAccountId,
},
);
const servicesListQueryKey = getListAccountServicesMetadataQueryKey({
cloudProvider: type,
id: cloudAccountId,
});
queryClient.setQueryData<ListServicesMetadata200 | undefined>(
queryClient.setQueryData<ListAccountServicesMetadata200 | undefined>(
servicesListQueryKey,
(prev) => {
if (!prev?.data?.services?.length) {
@@ -283,15 +279,10 @@ function ServiceDetails({
},
);
invalidateListServicesMetadata(
queryClient,
{
cloudProvider: type,
},
{
cloud_integration_id: cloudAccountId,
},
);
invalidateListAccountServicesMetadata(queryClient, {
cloudProvider: type,
id: cloudAccountId,
});
logEvent(`${type} Integration: Service settings saved`, {
cloudAccountId,

View File

@@ -55,7 +55,6 @@ const buildServiceDetailsResponse = (
},
},
},
telemetryCollectionStrategy: { aws: {} },
},
});

View File

@@ -0,0 +1,68 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { CloudintegrationtypesServiceDashboardDTO } from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { openInNewTab } from 'utils/navigation';
const DISABLED_TOOLTIP =
'Enable metrics collection for this service to view this dashboard.';
function DashboardCard({
dashboard,
isInteractive,
}: {
dashboard: CloudintegrationtypesServiceDashboardDTO;
isInteractive: boolean;
}): JSX.Element {
const dashboardId = dashboard.integrationDashboard?.dashboardId;
const isClickable = Boolean(dashboardId) && isInteractive;
const dashboardUrl = dashboardId ? `/dashboard/${dashboardId}` : '';
const { safeNavigate } = useSafeNavigate();
const interactiveProps = isClickable
? {
role: 'button',
tabIndex: 0,
onClick: (event: MouseEvent<HTMLDivElement>): void => {
if (event.metaKey || event.ctrlKey) {
openInNewTab(dashboardUrl);
return;
}
safeNavigate(dashboardUrl);
},
onKeyDown: (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
safeNavigate(dashboardUrl);
}
},
}
: {};
const card = (
<div
className={`aws-service-dashboard-item ${
isClickable
? 'aws-service-dashboard-item-clickable'
: 'aws-service-dashboard-item-disabled'
} `}
{...interactiveProps}
>
<div className="aws-service-dashboard-item-content">
<div className="aws-service-dashboard-item-title">{dashboard.title}</div>
<div className="aws-service-dashboard-item-description">
{dashboard.description}
</div>
</div>
</div>
);
if (!dashboardId) {
return <TooltipSimple title={DISABLED_TOOLTIP}>{card}</TooltipSimple>;
}
return card;
}
export default DashboardCard;

View File

@@ -53,6 +53,11 @@
}
}
&.aws-service-dashboard-item-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.aws-service-dashboard-item-content {
display: flex;
flex-direction: column;

View File

@@ -1,11 +1,9 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
CloudintegrationtypesDashboardDTO,
CloudintegrationtypesServiceDashboardDTO,
CloudintegrationtypesServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { withBasePath } from 'utils/basePath';
import DashboardCard from './DashboardCard';
import './ServiceDashboards.styles.scss';
function ServiceDashboards({
@@ -16,7 +14,6 @@ function ServiceDashboards({
isInteractive?: boolean;
}): JSX.Element {
const dashboards = service?.assets?.dashboards || [];
const { safeNavigate } = useSafeNavigate();
if (!dashboards.length) {
return <></>;
}
@@ -25,68 +22,20 @@ function ServiceDashboards({
<div className="aws-service-dashboards">
<div className="aws-service-dashboards-title">Dashboards</div>
<div className="aws-service-dashboards-items">
{dashboards.map((dashboard: CloudintegrationtypesDashboardDTO) => {
if (!dashboard.id) {
return null;
}
const dashboardUrl = `/dashboard/${dashboard.id}`;
return (
<div
key={dashboard.id}
className={`aws-service-dashboard-item ${
isInteractive ? 'aws-service-dashboard-item-clickable' : ''
}`}
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : -1}
onClick={(event): void => {
if (!isInteractive) {
return;
}
if (event.metaKey || event.ctrlKey) {
window.open(
withBasePath(dashboardUrl),
'_blank',
'noopener,noreferrer',
);
return;
}
safeNavigate(dashboardUrl);
}}
onAuxClick={(event): void => {
if (!isInteractive) {
return;
}
if (event.button === 1) {
window.open(
withBasePath(dashboardUrl),
'_blank',
'noopener,noreferrer',
);
}
}}
onKeyDown={(event): void => {
if (!isInteractive) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
safeNavigate(dashboardUrl);
}
}}
>
<div className="aws-service-dashboard-item-content">
<div className="aws-service-dashboard-item-title">
{dashboard.title}
</div>
<div className="aws-service-dashboard-item-description">
{dashboard.description}
</div>
</div>
</div>
);
})}
{dashboards.map(
(dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => {
const key =
dashboard.integrationDashboard?.dashboardId ||
`${dashboard.title}-${index}`;
return (
<DashboardCard
key={key}
dashboard={dashboard}
isInteractive={isInteractive}
/>
);
},
)}
</div>
</div>
);

View File

@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Skeleton } from 'antd';
import { useListServicesMetadata } from 'api/generated/services/cloudintegration';
import {
useListAccounts,
useListAccountServicesMetadata,
useListServicesMetadata,
} from 'api/generated/services/cloudintegration';
import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { IntegrationType } from 'container/Integrations/types';
@@ -20,17 +24,33 @@ function ServicesList({
}: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const hasValidCloudAccountId = Boolean(cloudAccountId);
const serviceQueryParams = hasValidCloudAccountId
? { cloud_integration_id: cloudAccountId }
: undefined;
const isAccountConnected = Boolean(cloudAccountId);
const { data: listAccountsResponse, isLoading: isAccountsLoading } =
useListAccounts({ cloudProvider: type });
const hasConnectedAccounts =
(listAccountsResponse?.data?.accounts?.length ?? 0) > 0;
const { data: servicesMetadata, isLoading } = useListServicesMetadata(
{
cloudProvider: type,
},
serviceQueryParams,
);
const { data: accountServicesMetadata, isLoading: isAccountServicesLoading } =
useListAccountServicesMetadata(
{ cloudProvider: type, id: cloudAccountId },
{ query: { enabled: isAccountConnected } },
);
const {
data: providerServicesMetadata,
isLoading: isProviderServicesLoading,
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
});
const servicesMetadata = hasConnectedAccounts
? accountServicesMetadata
: providerServicesMetadata;
const isLoading =
isAccountsLoading ||
(hasConnectedAccounts
? isAccountServicesLoading || !isAccountConnected
: isProviderServicesLoading);
const awsServices = useMemo(
() => servicesMetadata?.data?.services ?? [],

View File

@@ -10,7 +10,7 @@ import {
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
@@ -42,7 +42,6 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { isEmpty, isUndefined } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
@@ -77,7 +76,6 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -90,10 +88,9 @@ function LogsExplorerViewsContainer({
DEFAULT_PER_PAGE_VALUE,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const currentMinTimeRef = useRef<number>(minTime);
@@ -329,16 +326,6 @@ function LogsExplorerViewsContainer({
currentMinTimeRef.current !== minTime ||
orderByChanged
) {
// Recalculate global time when query changes i.e. stage and run query clicked
if (
!!requestData?.id &&
stagedQuery?.id &&
requestData?.id !== stagedQuery?.id &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
filter: listQuery?.filter || { expression: '' },
@@ -360,8 +347,6 @@ function LogsExplorerViewsContainer({
minTime,
activeLogId,
selectedPanelType,
dispatch,
selectedTime,
maxTime,
orderBy,
]);

View File

@@ -108,13 +108,21 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock(
'container/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => {
const { useQueryBuilder } = jest.requireActual(
'hooks/queryBuilder/useQueryBuilder',
);
const { useSyncTimeOnStagedQueryChange } = jest.requireActual(
'hooks/queryBuilder/useSyncTimeOnStagedQueryChange',
);
return function MockDateTimeSelection(): JSX.Element {
const { stagedQuery } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
return <div>MockDateTimeSelection</div>;
};
});
jest.mock(
'container/LogsExplorerChart',
() =>

View File

@@ -233,8 +233,10 @@
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
padding: 0px;
gap: 0;
margin: 4px;
.ant-typography {
.qb-tag-text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px !important;
@@ -244,7 +246,7 @@
padding: 2px 6px;
}
.close-icon {
> button {
display: flex;
align-items: center;
justify-content: center;
@@ -259,26 +261,26 @@
&.resource {
border: 1px solid color-mix(in srgb, var(--bg-aqua-400) 13%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-aqua-400);
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-aqua-400) 6%, transparent);
}
}
&.tag {
border: 1px solid color-mix(in srgb, var(--bg-sienna-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-sienna-400);
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
}
}
@@ -286,13 +288,13 @@
&.scope {
border: 1px solid color-mix(in srgb, var(--bg-robin-400) 20%, transparent);
.ant-typography {
.qb-tag-text {
color: var(--bg-robin-400);
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
font-size: 14px;
}
.close-icon {
> button {
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
}
}

View File

@@ -966,6 +966,7 @@ function QueryBuilderSearchV2(
>
<Tooltip title={chipValue}>
<TypographyText
className="qb-tag-text"
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {

View File

@@ -19,6 +19,7 @@ import {
useIsGlobalTimeQueryRefreshing,
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSyncTimeOnStagedQueryChange } from 'hooks/queryBuilder/useSyncTimeOnStagedQueryChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
@@ -187,6 +188,8 @@ function DateTimeSelection({
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
const getInputLabel = (
startTime?: Dayjs,
endTime?: Dayjs,

View File

@@ -0,0 +1,139 @@
import { renderHook } from '@testing-library/react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { useSyncTimeOnStagedQueryChange } from '../useSyncTimeOnStagedQueryChange';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
useDispatch: (): jest.Mock => mockDispatch,
useSelector: jest.fn(),
}));
jest.mock('store/actions', () => ({
UpdateTimeInterval: jest.fn((time: string) => ({
type: 'UPDATE_TIME_INTERVAL_THUNK',
payload: time,
})),
}));
const mockedUseSelector = useSelector as jest.Mock;
const mockedUpdateTimeInterval = UpdateTimeInterval as unknown as jest.Mock;
const setSelectedTime = (value: string): void => {
mockedUseSelector.mockImplementation(
(selector: (state: { globalTime: { selectedTime: string } }) => unknown) =>
selector({ globalTime: { selectedTime: value } }),
);
};
describe('useSyncTimeOnStagedQueryChange', () => {
beforeEach(() => {
jest.clearAllMocks();
setSelectedTime('1h');
});
it('does not dispatch on initial mount when stagedQueryId is undefined', () => {
renderHook(() => useSyncTimeOnStagedQueryChange(undefined));
expect(mockDispatch).not.toHaveBeenCalled();
});
it('does not dispatch on initial mount when stagedQueryId is already defined', () => {
renderHook(() => useSyncTimeOnStagedQueryChange('initial-id'));
expect(mockDispatch).not.toHaveBeenCalled();
});
it('does not dispatch when stagedQueryId transitions from undefined to defined (first staged query arriving)', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: undefined as string | undefined } },
);
rerender({ id: 'first-id' });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dispatches UpdateTimeInterval with current selectedTime when stagedQueryId changes', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
expect(mockDispatch).not.toHaveBeenCalled();
rerender({ id: 'second-id' });
expect(mockedUpdateTimeInterval).toHaveBeenCalledTimes(1);
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('1h');
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL_THUNK',
payload: '1h',
});
});
it('does not dispatch when selectedTime is "custom" even if stagedQueryId changes', () => {
setSelectedTime('custom');
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
rerender({ id: 'second-id' });
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockedUpdateTimeInterval).not.toHaveBeenCalled();
});
it('does not dispatch when only selectedTime changes (stagedQueryId stable)', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'stable-id' as string | undefined } },
);
setSelectedTime('5m');
rerender({ id: 'stable-id' });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dispatches once per distinct stagedQueryId change', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'a' as string | undefined } },
);
rerender({ id: 'b' });
rerender({ id: 'c' });
rerender({ id: 'c' }); // no change — should not dispatch again
expect(mockDispatch).toHaveBeenCalledTimes(2);
});
it('does not dispatch when stagedQueryId transitions from defined to undefined', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'first-id' as string | undefined } },
);
rerender({ id: undefined });
expect(mockDispatch).not.toHaveBeenCalled();
});
it('uses the latest selectedTime at the moment of stagedQueryId change', () => {
const { rerender } = renderHook(
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
{ initialProps: { id: 'a' as string | undefined } },
);
setSelectedTime('15m');
rerender({ id: 'b' });
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('15m');
});
});

View File

@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
// Push fresh min/max back into Redux whenever the staged query changes for a
// relative time interval. The data hooks that read minTime/maxTime from Redux
// otherwise keep refetching with the originally frozen window and the time
// picker displays a stale absolute range.
// ref - SigNoz/signoz#8277
export function useSyncTimeOnStagedQueryChange(
stagedQueryId: string | undefined,
): void {
const dispatch = useDispatch();
const selectedTime = useSelector<AppState, GlobalReducer['selectedTime']>(
(state) => state.globalTime.selectedTime,
);
const prevStagedQueryIdRef = useRef<string | undefined>();
useEffect(() => {
const prevId = prevStagedQueryIdRef.current;
const currentId = stagedQueryId;
prevStagedQueryIdRef.current = currentId;
if (
prevId !== undefined &&
currentId !== undefined &&
prevId !== currentId &&
selectedTime !== 'custom'
) {
dispatch(UpdateTimeInterval(selectedTime));
}
}, [stagedQueryId, selectedTime, dispatch]);
}

View File

@@ -0,0 +1,66 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getTraceAggregations } from 'api/generated/services/tracedetail';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import useGetTraceAggregations from '../useGetTraceAggregations';
jest.mock('api/generated/services/tracedetail', () => ({
__esModule: true,
getTraceAggregations: jest
.fn()
.mockResolvedValue({ status: 'success', data: { aggregations: [] } }),
}));
const mockApi = getTraceAggregations as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
const aggregations = [
{ field: { name: 'service.name' }, aggregation: 'execution_time_percentage' },
] as never;
describe('useGetTraceAggregations', () => {
beforeEach(() => mockApi.mockClear());
it('fetches when enabled with a traceId and aggregations', async () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: true }),
{ wrapper },
);
await waitFor(() => expect(mockApi).toHaveBeenCalledTimes(1));
expect(mockApi).toHaveBeenCalledWith({ traceID: 't1' }, { aggregations });
});
it('does not fetch when disabled', () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: false }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
it('does not fetch without a traceId', () => {
renderHook(
() => useGetTraceAggregations({ traceId: '', aggregations, enabled: true }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
it('does not fetch with no aggregations requested', () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations: [], enabled: true }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import { useQuery, UseQueryResult } from 'react-query';
import {
GetTraceAggregations200,
SpantypesSpanAggregationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getTraceAggregations } from 'api/generated/services/tracedetail';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
interface UseGetTraceAggregationsProps {
traceId: string;
aggregations: SpantypesSpanAggregationDTO[];
enabled: boolean;
}
type UseGetTraceAggregations = UseQueryResult<GetTraceAggregations200>;
/**
* Fetches trace aggregations on demand — gate via `enabled` so the request
* fires only when the Analytics panel is open. The query key includes the
* requested fields, so changing the color-by field refetches.
*/
const useGetTraceAggregations = ({
traceId,
aggregations,
enabled,
}: UseGetTraceAggregationsProps): UseGetTraceAggregations =>
useQuery({
queryFn: () => getTraceAggregations({ traceID: traceId }, { aggregations }),
queryKey: [REACT_QUERY_KEY.GET_TRACE_AGGREGATIONS, traceId, aggregations],
enabled: enabled && !!traceId && aggregations.length > 0,
refetchOnWindowFocus: false,
});
export default useGetTraceAggregations;

View File

@@ -1,30 +1,29 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV3 from 'api/trace/getTraceV3';
import getTraceV4 from 'api/trace/getTraceV4';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
} from 'types/api/trace/getTraceV3';
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
const useGetTraceV4 = (props: GetTraceV4PayloadProps): UseTraceV4 =>
useQuery({
queryFn: () => getTraceV3(props),
queryFn: () => getTraceV4(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
REACT_QUERY_KEY.GET_TRACE_V4_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
props.aggregations,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseTraceV3 = UseQueryResult<
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
type UseTraceV4 = UseQueryResult<
SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV3;
export default useGetTraceV4;

View File

@@ -33,6 +33,15 @@
scrollbar-width: none;
}
.state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 120px;
padding: 24px 12px;
}
.list {
display: grid;
grid-template-columns: auto auto 1fr;

View File

@@ -1,21 +1,28 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import cx from 'classnames';
import { DetailsHeader } from 'components/DetailsPanel';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import {
SpantypesSpanAggregationDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useTraceStore } from '../../stores/traceStore';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../../utils/aggregations';
import AnalyticsTabContent, { AnalyticsRow } from './AnalyticsTabContent';
import styles from './AnalyticsPanel.module.scss';
@@ -35,10 +42,31 @@ function AnalyticsPanel({
onClose,
onTabChange,
}: AnalyticsPanelProps): JSX.Element | null {
const aggregations = useTraceStore((s) => s.aggregations);
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const colorByField = useTraceStore((s) => s.colorByField);
const colorByFieldName = colorByField.name;
const isDarkMode = useIsDarkMode();
// Fetch exec-time % + span count for the current color-by field only, and
// only while the panel is open. Changing the field refetches via the key.
const aggregationsRequest = useMemo<SpantypesSpanAggregationDTO[]>(() => {
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
const field = colorByField as unknown as TelemetrytypesTelemetryFieldKeyDTO;
return [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
];
}, [colorByField]);
const { data, isLoading, isError } = useGetTraceAggregations({
traceId: traceId || '',
aggregations: aggregationsRequest,
enabled: isOpen,
});
const aggregations = data?.data.aggregations;
const execTimePct = useMemo(
() =>
findAggregationMap(
@@ -55,38 +83,39 @@ function AnalyticsPanel({
[aggregations, colorByFieldName],
);
const execTimeRows = useMemo(() => {
const execTimeRows = useMemo<AnalyticsRow[]>(() => {
if (!execTimePct) {
return [];
}
return Object.entries(execTimePct)
.sort(([, a], [, b]) => b - a)
.map(([group, percentage]) => {
const pair = generateColorPair(group);
return {
group,
percentage,
color: isDarkMode ? pair.color : pair.colorDark,
widthPct: Math.min(percentage, 100),
label: `${percentage.toFixed(2)}%`,
};
})
.sort((a, b) => b.percentage - a.percentage);
});
}, [execTimePct, isDarkMode]);
const spanCountRows = useMemo(() => {
const spanCountRows = useMemo<AnalyticsRow[]>(() => {
if (!spanCounts) {
return [];
}
const max = Math.max(...Object.values(spanCounts), 1);
return Object.entries(spanCounts)
.sort(([, a], [, b]) => b - a)
.map(([group, count]) => {
const pair = generateColorPair(group);
return {
group,
count,
max,
color: isDarkMode ? pair.color : pair.colorDark,
widthPct: (count / max) * 100,
label: String(count),
};
})
.sort((a, b) => b.count - a.count);
});
}, [spanCounts, isDarkMode]);
if (!isOpen) {
@@ -132,65 +161,23 @@ function AnalyticsPanel({
<div className={styles.tabsScroll}>
<TabsContent value="exec-time">
<div className={styles.list}>
{execTimeRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueWide)}>
{row.percentage.toFixed(2)}%
</span>
</div>
</>
))}
</div>
<AnalyticsTabContent
isLoading={isLoading}
isError={isError}
fieldName={colorByFieldName}
rows={execTimeRows}
valueVariant="wide"
/>
</TabsContent>
<TabsContent value="spans">
<div className={styles.list}>
{spanCountRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${(row.count / row.max) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueNarrow)}>
{row.count}
</span>
</div>
</>
))}
</div>
<AnalyticsTabContent
isLoading={isLoading}
isError={isError}
fieldName={colorByFieldName}
rows={spanCountRows}
valueVariant="narrow"
/>
</TabsContent>
</div>
</TabsRoot>

View File

@@ -0,0 +1,84 @@
import { Fragment } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import styles from './AnalyticsPanel.module.scss';
export interface AnalyticsRow {
group: string;
color: string;
widthPct: number;
label: string;
}
interface AnalyticsTabContentProps {
isLoading: boolean;
isError: boolean;
fieldName: string;
rows: AnalyticsRow[];
valueVariant: 'wide' | 'narrow';
}
// Loading / error / empty render in place of the rows so the tabs stay visible.
function AnalyticsTabContent({
isLoading,
isError,
fieldName,
rows,
valueVariant,
}: AnalyticsTabContentProps): JSX.Element {
if (isLoading) {
return (
<div className={styles.state}>
<Spinner height="auto" />
</div>
);
}
if (isError) {
return (
<div className={styles.state}>
<Typography.Text>Couldn&apos;t load analytics</Typography.Text>
</div>
);
}
if (rows.length === 0) {
return (
<div className={styles.state}>
<Typography.Text>No data for {fieldName}</Typography.Text>
</div>
);
}
return (
<div className={styles.list}>
{rows.map((row) => (
<Fragment key={row.group}>
<div className={styles.dot} style={{ backgroundColor: row.color }} />
<span className={styles.serviceName}>{row.group}</span>
<div className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${row.widthPct}%`,
backgroundColor: row.color,
}}
/>
</div>
<span
className={cx(
styles.value,
valueVariant === 'wide' ? styles.valueWide : styles.valueNarrow,
)}
>
{row.label}
</span>
</div>
</Fragment>
))}
</div>
);
}
export default AnalyticsTabContent;

View File

@@ -0,0 +1,144 @@
import { screen } from '@testing-library/react';
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
import { render } from 'tests/test-utils';
import { DEFAULT_COLOR_BY_FIELD } from '../../../constants';
import { useTraceStore } from '../../../stores/traceStore';
import AnalyticsPanel from '../AnalyticsPanel';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
jest.mock('hooks/trace/useGetTraceAggregations', () => ({
__esModule: true,
default: jest.fn(),
}));
// Isolate the panel's own logic from the floating-panel chrome.
jest.mock('periscope/components/FloatingPanel', () => ({
__esModule: true,
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('components/DetailsPanel', () => ({
__esModule: true,
DetailsHeader: (): JSX.Element => <div data-testid="details-header" />,
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="spinner" />,
}));
const mockHook = useGetTraceAggregations as jest.Mock;
const noop = (): void => undefined;
const renderPanel = (isOpen = true): ReturnType<typeof render> =>
render(<AnalyticsPanel isOpen={isOpen} onClose={noop} onTabChange={noop} />);
const aggregationsResponse = {
status: 'success',
data: {
aggregations: [
{
field: { name: 'service.name' },
aggregation: 'execution_time_percentage',
value: { api: 80, db: 20 },
},
{
field: { name: 'service.name' },
aggregation: 'span_count',
value: { api: 5, db: 2 },
},
],
},
};
describe('AnalyticsPanel', () => {
beforeEach(() => {
mockHook.mockReset();
useTraceStore.setState({ colorByField: DEFAULT_COLOR_BY_FIELD });
});
it('renders nothing when closed and does not enable the fetch', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
const { container } = renderPanel(false);
expect(container).toBeEmptyDOMElement();
expect(mockHook).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it('requests both aggregations for the current color-by field when open', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderPanel();
expect(mockHook).toHaveBeenCalledWith(
expect.objectContaining({
traceId: 'trace-123',
enabled: true,
aggregations: [
{
field: DEFAULT_COLOR_BY_FIELD,
aggregation: 'execution_time_percentage',
},
{ field: DEFAULT_COLOR_BY_FIELD, aggregation: 'span_count' },
],
}),
);
});
it('shows the loading state with the tabs still visible', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderPanel();
expect(screen.getByTestId('spinner')).toBeInTheDocument();
// tabs stay visible while loading
expect(screen.getByText('% exec time')).toBeInTheDocument();
expect(screen.getByText('Spans')).toBeInTheDocument();
});
it('shows an error state when the request fails', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderPanel();
expect(screen.getByText(/couldn't load analytics/i)).toBeInTheDocument();
});
it('renders rows for the current field on success', () => {
mockHook.mockReturnValue({
data: aggregationsResponse,
isLoading: false,
isError: false,
});
renderPanel();
expect(screen.getByText('api')).toBeInTheDocument();
expect(screen.getByText('80.00%')).toBeInTheDocument();
});
it('shows an empty state when the field has no data', () => {
mockHook.mockReturnValue({
data: { status: 'success', data: { aggregations: [] } },
isLoading: false,
isError: false,
});
renderPanel();
expect(screen.getByText(/no data for service.name/i)).toBeInTheDocument();
});
});

View File

@@ -1,3 +1,6 @@
// Applied to both the menu content and the submenu content (each renders in
// its own portal with a default z-index of 50) so both stack above
// FloatingPanel (z-index 999).
.traceOptionsDropdown {
z-index: 1100;
}

View File

@@ -1,7 +1,16 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
@@ -14,6 +23,9 @@ interface TraceOptionsMenuProps {
onOpenPreviewFields: () => void;
}
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
// because the simple preset offers no way to style the submenu content,
// which renders in its own portal and needs a z-index above FloatingPanel.
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
@@ -25,78 +37,52 @@ function TraceOptionsMenu({
(s) => s.availableColorByOptions,
);
const menuItems: MenuItem[] = useMemo(() => {
const items: MenuItem[] = [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
{
key: 'preview-fields',
label: 'Preview fields',
onClick: onOpenPreviewFields,
},
];
// Only show the "Colour by" submenu if there's an actual choice to make.
if (availableColorByOptions.length > 1) {
items.push({
key: 'colour-by',
label: 'Colour by',
children: [
{
type: 'group',
label: 'COLOUR BY',
children: [
{
type: 'radio-group',
value: colorByField.name,
onChange: (name: string): void => {
const next = availableColorByOptions.find(
(o) => o.field.name === name,
);
if (next) {
setColorByField(next.field);
}
},
children: availableColorByOptions.map((opt) => ({
type: 'radio',
key: opt.field.name,
label: opt.label,
value: opt.field.name,
})),
},
],
},
],
});
const handleColorByChange = (name: string): void => {
const next = availableColorByOptions.find((o) => o.field.name === name);
if (next) {
setColorByField(next.field);
}
return items;
}, [
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
colorByField.name,
setColorByField,
availableColorByOptions,
]);
};
return (
<Dropdown
menu={{ items: menuItems }}
align="start"
className={styles.traceOptionsDropdown}
>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</Dropdown>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={styles.traceOptionsDropdown}>
<DropdownMenuItem clickable onSelect={onToggleTraceDetails}>
{showTraceDetails ? 'Hide trace details' : 'Show trace details'}
</DropdownMenuItem>
<DropdownMenuItem clickable onSelect={onOpenPreviewFields}>
Preview fields
</DropdownMenuItem>
{/* Only show the "Colour by" submenu if there's an actual choice to make. */}
{availableColorByOptions.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Colour by</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
<DropdownMenuLabel>COLOUR BY</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={colorByField.name}
onValueChange={handleColorByChange}
>
{availableColorByOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.field.name} value={opt.field.name}>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,6 +1,9 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout } from '../computeVisualLayout';
import {
computeVisualLayout,
WIDE_GROUP_THRESHOLD,
} from '../computeVisualLayout';
function makeSpan(
overrides: Partial<FlamegraphSpan> & {
@@ -472,4 +475,177 @@ describe('computeVisualLayout', () => {
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
// --- Wide-group fast path (> WIDE_GROUP_THRESHOLD siblings) ---
// Past the threshold the layout switches to exact overlap-only packing to
// avoid the O(N^2) connector-avoidance spiral. These lock in correctness and
// the no-overlap invariant at scale.
function noRowHasOverlap(
layout: ReturnType<typeof computeVisualLayout>,
): void {
for (const row of layout.visualRows) {
const sorted = [...row].sort((a, b) => a.timestamp - b.timestamp);
for (let i = 1; i < sorted.length; i++) {
const prevEnd = sorted[i - 1].timestamp + sorted[i - 1].durationNano / 1e6;
expect(sorted[i].timestamp).toBeGreaterThanOrEqual(prevEnd);
}
}
}
it('should pack thousands of sequential leaf siblings into 1 row (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 2000 strictly sequential (non-overlapping) children
for (let i = 0; i < 2000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6, // 5ms, ends before next starts
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.totalVisualRows).toBe(2); // all siblings share row 1
for (const k of kids) {
expect(layout.spanToVisualRow[k.spanId]).toBe(1);
}
noRowHasOverlap(layout);
});
it('should pack thousands of fully-overlapping leaf siblings without violations (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 1000 children all spanning the same window → each needs its own row
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.totalVisualRows).toBe(1001); // root + 1000 stacked rows
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1001);
noRowHasOverlap(layout);
});
it('should keep non-leaf subtrees adjacent within a wide mixed group (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6,
}),
);
}
// One of the wide siblings has a child of its own
const grandchild = makeSpan({
spanId: 'gc',
parentSpanId: 'k500',
timestamp: 5000,
durationNano: 2e6,
});
const layout = computeVisualLayout([[root], kids, [grandchild]]);
const parentRow = layout.spanToVisualRow['k500'];
const gcRow = layout.spanToVisualRow['gc'];
expect(gcRow - parentRow).toBe(1); // subtree adjacency preserved
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1002);
noRowHasOverlap(layout);
});
// --- Regression guards for the wide-trace layout spiral ---
// The pre-fix algorithm's connector-avoidance checks formed a positive-
// feedback loop on wide SCATTERED groups (short spans spread across the
// parent window with modest concurrency): each pushed-down child stamped
// connector points on intermediate rows, pushing later children even
// higher. Sequential and fully-overlapping groups (tests above) do NOT
// trigger it — these two shapes do, and encode its failure signatures.
function makeScatteredStar(
childCount: number,
spacingMs: number,
durationMs: number,
): FlamegraphSpan[][] {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: (childCount * spacingMs + durationMs) * 1e6,
});
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < childCount; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * spacingMs,
durationNano: durationMs * 1e6,
}),
);
}
return [[root], kids];
}
it('should not spiral row count on a scattered group just above the threshold', () => {
// Children of 50ms each, starting every 10ms → max temporal concurrency
// is 6 (each span overlaps the next 5). The old algorithm spiraled this
// shape to ~1 row per child; overlap-only packing needs ~7 including the
// root. Sized just above WIDE_GROUP_THRESHOLD so a regression fails fast
// (sub-second) rather than burning CI time.
const count = WIDE_GROUP_THRESHOLD + 88;
const layout = computeVisualLayout(makeScatteredStar(count, 10, 50));
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(count + 1);
// Generous slack over the optimal ~7 — but orders of magnitude below the
// one-row-per-child the spiral produced.
expect(layout.totalVisualRows).toBeLessThan(30);
noRowHasOverlap(layout);
});
it('should be faster through the fast path despite one extra span', () => {
// Identical scattered shape on both sides of the gate: THRESHOLD children
// run the original connector-avoidance path, THRESHOLD + 1 run the
// overlap-only fast path. With one MORE span to place, the fast path can
// only win because the algorithm is cheaper — a self-calibrating
// comparison (same machine, same process) with no absolute time budget.
// On this shape the avoidance path spirals (~tens of ms) while the fast
// path stays ~1ms, so the margin is well past timer noise. Also guards
// the gate itself: if everything routed to one path, one extra span can
// never be faster.
const avoidancePathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD, 10, 50);
const fastPathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD + 1, 10, 50);
// Warm-up runs so JIT compilation doesn't skew either side.
computeVisualLayout(avoidancePathInput);
computeVisualLayout(fastPathInput);
const timeOf = (input: FlamegraphSpan[][]): number => {
const start = performance.now();
computeVisualLayout(input);
return performance.now() - start;
};
// Best-of-3 per side to shave off GC pauses and scheduler noise.
const RUNS = [0, 1, 2];
const avoidanceMs = Math.min(...RUNS.map(() => timeOf(avoidancePathInput)));
const fastMs = Math.min(...RUNS.map(() => timeOf(fastPathInput)));
expect(fastMs).toBeLessThan(avoidanceMs);
});
});

View File

@@ -18,6 +18,82 @@ export interface VisualLayout {
totalVisualRows: number;
}
// Above this many siblings under one parent, the connector-avoidance refinement
// (Checks 2 & 3) is both visually meaningless — the row is already a dense wall —
// and quadratic: every child deposits a connector point on each intermediate row,
// which pushes later children even higher, which deposits more points. That
// feedback loop inflates a layout needing ~50 rows to thousands and never
// finishes on wide traces. Past the threshold we pack by overlap only.
// Exported so the regression tests stay anchored to the real gate value.
export const WIDE_GROUP_THRESHOLD = 512;
/**
* Segment tree over rows that answers "lowest row index >= `from` whose smallest
* span start-time is >= `end`" in O(log rows). Used to place a large group of
* leaf siblings by overlap only: because siblings are processed in descending
* start order, every already-placed span on a row starts at or after the current
* one, so [start, end] overlaps a row iff some span there starts before `end` —
* i.e. the row is free iff its minimum start >= end. Each node stores the max of
* its subtree's per-row minimum starts so a free row can be found by descent.
*/
class LowestFreeRow {
private readonly size: number;
private readonly tree: Float64Array;
constructor(rows: number) {
let size = 1;
while (size < rows) {
size *= 2;
}
this.size = size;
this.tree = new Float64Array(size * 2).fill(Infinity);
}
place(row: number, start: number): void {
let i = row + this.size;
// A row's key is the minimum start among its spans. Children are processed
// in descending start order so a leaf's start is the new minimum, but a
// non-leaf subtree's descendant can land on a row out of order — take min.
if (start >= this.tree[i]) {
return;
}
this.tree[i] = start;
for (i >>= 1; i >= 1; i >>= 1) {
const next = Math.max(this.tree[2 * i], this.tree[2 * i + 1]);
if (this.tree[i] === next) {
break;
}
this.tree[i] = next;
}
}
lowestFrom(from: number, end: number): number {
return this.descend(1, 0, this.size - 1, from, end);
}
private descend(
node: number,
lo: number,
hi: number,
from: number,
end: number,
): number {
if (hi < from || this.tree[node] < end) {
return -1;
}
if (lo === hi) {
return lo;
}
const mid = (lo + hi) >> 1;
const left = this.descend(2 * node, lo, mid, from, end);
if (left !== -1) {
return left;
}
return this.descend(2 * node + 1, mid + 1, hi, from, end);
}
}
/**
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
*
@@ -214,7 +290,53 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
arr.push(point);
}
// Fast path for a parent with a very large group of children: pack by overlap
// only (descending greedy), skipping the quadratic connector-avoidance that
// spirals at this scale. Leaf children — the bulk of a wide trace — are placed
// in O(log rows) via the segment tree; the rare non-leaf subtree falls back to
// findPlacement against the shared interval map. Both structures are kept in
// sync so each placement sees all prior occupancy. Same ShapeEntry[] contract.
function computeWideShape(
rootSpan: FlamegraphSpan,
children: FlamegraphSpan[],
): ShapeEntry[] {
const shape: ShapeEntry[] = [{ span: rootSpan, relativeRow: 0 }];
const localIntervals = new Map<number, Array<[number, number]>>();
// Children occupy relative rows 1..children.length in the worst case.
const finder = new LowestFreeRow(children.length + 2);
const occupy = (row: number, span: FlamegraphSpan): void => {
const s = span.timestamp;
const e = span.timestamp + span.durationNano / 1e6;
shape.push({ span, relativeRow: row });
addIntervalTo(localIntervals, row, s, e);
finder.place(row, s);
};
for (const child of children) {
if (childrenMap.has(child.spanId)) {
// Non-leaf: place its whole subtree shape as a unit via findPlacement.
const childShape = computeSubtreeShape(child);
const offset = findPlacement(childShape, 1, localIntervals);
for (const entry of childShape) {
occupy(entry.relativeRow + offset, entry.span);
}
} else {
const end = child.timestamp + child.durationNano / 1e6;
occupy(finder.lowestFrom(1, end), child);
}
}
return shape;
}
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
const children = childrenMap.get(rootSpan.spanId);
if (children && children.length > WIDE_GROUP_THRESHOLD) {
return computeWideShape(rootSpan, children);
}
const localIntervals = new Map<number, Array<[number, number]>>();
const localConnectorPoints = new Map<number, number[]>();
const shape: ShapeEntry[] = [];
@@ -225,7 +347,6 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
shape.push({ span: rootSpan, relativeRow: 0 });
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
const children = childrenMap.get(rootSpan.spanId);
if (children) {
for (const child of children) {
const childShape = computeSubtreeShape(child);

View File

@@ -94,7 +94,7 @@ export function useVisualLayoutWorker(spans: FlamegraphSpan[][]): {
cleanup();
};
// Timeout: if worker doesn't respond in 30s, terminate and error
// Timeout: if worker doesn't respond in 15s, terminate and error
const WORKER_TIMEOUT_MS = 15000;
const timeoutId = setTimeout(() => {
if (requestIdRef.current === currentId && isComputingRef.current) {

View File

@@ -119,6 +119,7 @@
gap: 12px;
margin-bottom: 14px;
align-items: center;
background: var(--l3-background);
}
.searchInput {
@@ -126,16 +127,13 @@
padding: 6px 8px;
background: var(--l3-background);
:global(.ant-input-prefix) {
height: 18px;
margin-inline-end: 6px;
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
svg {
opacity: 0.4;
}
&,
input {
font-size: 14px;
line-height: 18px;

View File

@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV3SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { GetTraceV4SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
@@ -22,7 +22,7 @@ interface ITraceWaterfallProps {
localUncollapsedNodes: Set<string>;
setLocalUncollapsedNodes: Dispatch<SetStateAction<Set<string>>>;
traceData:
| SuccessResponse<GetTraceV3SuccessResponse, unknown>
| SuccessResponse<GetTraceV4SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;

View File

@@ -0,0 +1,34 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAvailableColorByFieldNames } from '../utils';
const span = (partial: Partial<SpanV3>): SpanV3 =>
({ level: 1, resource: {}, attributes: {}, ...partial }) as SpanV3;
describe('getAvailableColorByFieldNames', () => {
it('returns [] for an empty span set', () => {
expect(getAvailableColorByFieldNames([])).toStrictEqual([]);
});
it('offers a field if any span carries it, in option order', () => {
const spans = [
span({ resource: { 'service.name': 'api' } }),
// k8s.node.name lives on a non-root span — still offered
span({ resource: { 'k8s.node.name': 'node-1' } }),
];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual([
'service.name',
'k8s.node.name',
]);
});
it('reads from attributes when the key is not on resource', () => {
const spans = [span({ attributes: { 'host.name': 'box-1' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('does not offer fields no span carries', () => {
const spans = [span({ resource: { 'service.name': 'api' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
});
});

View File

@@ -8,23 +8,17 @@ import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV3 from 'hooks/trace/useGetTraceV3';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import {
SpanV3,
TraceDetailV3URLProps,
WaterfallAggregationRequest,
} from 'types/api/trace/getTraceV3';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from './constants';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { AGGREGATIONS } from './utils/aggregations';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
@@ -34,6 +28,7 @@ import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
@@ -103,17 +98,6 @@ function TraceDetailsV3(): JSX.Element {
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
// upfront so a future color-by-field switch doesn't need to refetch.
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
() =>
COLOR_BY_FIELDS.flatMap((field) => [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
]),
[],
);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
@@ -121,7 +105,6 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
});
const queryParams = fullDataLoadedRef.current
@@ -130,19 +113,17 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV3({
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
aggregations: queryParams.aggregations,
});
const allSpans = traceData?.payload?.spans || [];
@@ -150,6 +131,13 @@ function TraceDetailsV3(): JSX.Element {
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
@@ -157,7 +145,6 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
}
@@ -382,7 +369,7 @@ function TraceDetailsV3(): JSX.Element {
);
return (
<TraceStoreSync aggregations={traceData?.payload?.aggregations}>
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}

View File

@@ -2,21 +2,20 @@ import { ReactNode, useEffect } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
import {
setTraceStoreAggregations,
setTraceStoreAvailableColorByFields,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
aggregations: WaterfallAggregationResponse[] | undefined;
availableColorByFields: string[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (the `aggregations` prop, `userPreferences`
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
@@ -25,15 +24,15 @@ interface TraceStoreSyncProps {
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({
aggregations,
availableColorByFields,
children,
}: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAggregations(aggregations);
}, [aggregations]);
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);

View File

@@ -0,0 +1,71 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import { UserPreference } from 'types/api/preferences/preference';
import { COLOR_BY_OPTIONS, DEFAULT_COLOR_BY_FIELD } from '../../constants';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreUserPreferences,
useTraceStore,
} from '../traceStore';
const colorByPref = (fieldName: string): UserPreference[] => [
{
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
value: fieldName,
} as UserPreference,
];
const optionNames = (): string[] =>
useTraceStore.getState().availableColorByOptions.map((o) => o.field.name);
describe('traceStore color-by gating', () => {
beforeEach(() => {
useTraceStore.setState({
availableColorByFieldNames: undefined,
userPreferences: null,
colorByField: DEFAULT_COLOR_BY_FIELD,
availableColorByOptions: COLOR_BY_OPTIONS.filter(
(o) => o.field.name === DEFAULT_COLOR_BY_FIELD.name,
),
});
});
it('offers only the default field before spans load', () => {
expect(optionNames()).toStrictEqual([DEFAULT_COLOR_BY_FIELD.name]);
expect(useTraceStore.getState().colorByField).toStrictEqual(
DEFAULT_COLOR_BY_FIELD,
);
});
it('offers the default plus any field present on loaded spans', () => {
setTraceStoreAvailableColorByFields(['host.name']);
expect(optionNames()).toStrictEqual([
DEFAULT_COLOR_BY_FIELD.name,
'host.name',
]);
});
it('honors the persisted color-by field when it is available', () => {
setTraceStoreAvailableColorByFields(['host.name']);
setTraceStoreUserPreferences(colorByPref('host.name'));
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
});
it('falls back to the default when the persisted field is not available', () => {
setTraceStoreUserPreferences(colorByPref('host.name'));
setTraceStoreAvailableColorByFields(['k8s.node.name']);
expect(useTraceStore.getState().colorByField.name).toBe(
DEFAULT_COLOR_BY_FIELD.name,
);
expect(optionNames()).toStrictEqual([
DEFAULT_COLOR_BY_FIELD.name,
'k8s.node.name',
]);
});
it('trusts the persisted field while spans are still loading', () => {
// availableColorByFieldNames stays undefined (loading) — do not flip to default
setTraceStoreUserPreferences(colorByPref('host.name'));
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
});
});

View File

@@ -1,7 +1,6 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import { UserPreference } from 'types/api/preferences/preference';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { create } from 'zustand';
@@ -11,10 +10,6 @@ import {
ColorByOption,
DEFAULT_COLOR_BY_FIELD,
} from '../constants';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../utils/aggregations';
import { toTelemetryFieldKey } from '../utils/previewFields';
interface MutateOptions {
@@ -30,7 +25,9 @@ type MutateUserPreference = (
interface TraceStoreState {
// --- Inputs synced from React layer via TraceStoreSync ---
aggregations: WaterfallAggregationResponse[] | undefined;
// Fields present on loaded spans; gates color-by options. `undefined` while
// loading so we keep trusting the persisted field.
availableColorByFieldNames: string[] | undefined;
userPreferences: UserPreference[] | null;
updateUserPreferenceInContext: UpdateUserPreferenceInContext | null;
mutateUserPreference: MutateUserPreference | null;
@@ -41,9 +38,7 @@ interface TraceStoreState {
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
setAggregations: (
aggregations: WaterfallAggregationResponse[] | undefined,
) => void;
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {
updateUserPreferenceInContext: UpdateUserPreferenceInContext;
@@ -71,23 +66,18 @@ function getPersistedColorByField(
/**
* Re-derives `colorByField` + `availableColorByOptions` from the two inputs.
* Preserves the "trust persisted while aggregations load" rule so the
* flamegraph doesn't repaint when the aggregations response arrives.
* Preserves the "trust persisted while spans load" rule so the flamegraph
* doesn't repaint when the waterfall response arrives.
*/
function deriveColorState(
aggregations: WaterfallAggregationResponse[] | undefined,
availableColorByFieldNames: string[] | undefined,
userPreferences: UserPreference[] | null,
): Pick<TraceStoreState, 'colorByField' | 'availableColorByOptions'> {
const isFieldAvailable = (fieldName: string): boolean => {
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
return true;
}
const map = findAggregationMap(
aggregations,
AGGREGATIONS.EXEC_TIME_PCT,
fieldName,
);
return !!map && Object.keys(map).length > 0;
return !!availableColorByFieldNames?.includes(fieldName);
};
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
@@ -95,10 +85,10 @@ function deriveColorState(
);
const persistedColorByField = getPersistedColorByField(userPreferences);
// While aggregations are loading, trust persisted — don't flip to default
// just because we haven't confirmed availability yet.
// While loading, trust persisted — don't flip to default prematurely.
const colorByField =
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
availableColorByFieldNames === undefined ||
isFieldAvailable(persistedColorByField.name)
? persistedColorByField
: DEFAULT_COLOR_BY_FIELD;
@@ -134,7 +124,7 @@ function derivePreviewFields(
}
export const useTraceStore = create<TraceStoreState>()((set, get) => ({
aggregations: undefined,
availableColorByFieldNames: undefined,
userPreferences: null,
updateUserPreferenceInContext: null,
mutateUserPreference: null,
@@ -145,19 +135,19 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
),
previewFields: [],
setAggregations: (aggregations): void => {
setAvailableColorByFields: (availableColorByFieldNames): void => {
const { userPreferences } = get();
set({
aggregations,
...deriveColorState(aggregations, userPreferences),
availableColorByFieldNames,
...deriveColorState(availableColorByFieldNames, userPreferences),
});
},
setUserPreferences: (userPreferences): void => {
const { aggregations } = get();
const { availableColorByFieldNames } = get();
set({
userPreferences,
...deriveColorState(aggregations, userPreferences),
...deriveColorState(availableColorByFieldNames, userPreferences),
previewFields: derivePreviewFields(userPreferences),
});
},
@@ -235,9 +225,9 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
},
}));
export const setTraceStoreAggregations = (
aggregations: WaterfallAggregationResponse[] | undefined,
): void => useTraceStore.getState().setAggregations(aggregations);
export const setTraceStoreAvailableColorByFields = (
fieldNames: string[] | undefined,
): void => useTraceStore.getState().setAvailableColorByFields(fieldNames);
export const setTraceStoreUserPreferences = (
userPreferences: UserPreference[] | null,

View File

@@ -1,5 +1,6 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_OPTIONS } from './constants';
import {
ColorPair,
generateColorPair,
@@ -109,3 +110,14 @@ export function resolveSpanColor(
}
return generateColorPair(getSpanGroupValue(span, colorByFieldName));
}
/**
* Color-by fields present on any of the given spans — replaces the old
* server-side aggregation gating. `service.name` is always offered by the store
* regardless of this list.
*/
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
return COLOR_BY_OPTIONS.filter((opt) =>
spans.some((s) => getSpanAttribute(s, opt.field.name)),
).map((opt) => opt.field.name);
}

View File

@@ -1,19 +1,19 @@
import {
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
SpantypesSpanAggregationResultDTO,
SpantypesSpanAggregationTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export const AGGREGATIONS = {
EXEC_TIME_PCT: 'execution_time_percentage',
SPAN_COUNT: 'span_count',
DURATION: 'duration',
} as const satisfies Record<string, WaterfallAggregationType>;
EXEC_TIME_PCT: SpantypesSpanAggregationTypeDTO.execution_time_percentage,
SPAN_COUNT: SpantypesSpanAggregationTypeDTO.span_count,
DURATION: SpantypesSpanAggregationTypeDTO.duration,
} as const;
export function getAggregationMap(
aggregations: WaterfallAggregationResponse[] | undefined,
type: WaterfallAggregationType,
aggregations: SpantypesSpanAggregationResultDTO[] | undefined,
type: SpantypesSpanAggregationTypeDTO,
fieldName: string,
): Record<string, number> | undefined {
): Record<string, number> | null | undefined {
return aggregations?.find(
(a) => a.aggregation === type && a.field.name === fieldName,
)?.value;

View File

@@ -53,20 +53,20 @@ export default function WorkspaceBlocked(): JSX.Element {
const { t } = useTranslation(['workspaceLocked']);
useEffect((): void => {
logEvent('Workspace Blocked: Screen Viewed', {});
void logEvent('Workspace Blocked: Screen Viewed', {});
}, []);
const handleContactUsClick = (): void => {
logEvent('Workspace Blocked: Contact Us Clicked', {});
void logEvent('Workspace Blocked: Contact Us Clicked', {});
};
const handleTabClick = (key: string): void => {
logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key });
void logEvent('Workspace Blocked: Screen Tabs Clicked', { tabKey: key });
};
const handleCollapseChange = (key: string | string[]): void => {
const lastKey = Array.isArray(key) ? key.slice(-1)[0] : key;
logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', {
void logEvent('Workspace Blocked: Screen Tab FAQ Item Clicked', {
panelKey: lastKey,
});
};
@@ -109,7 +109,7 @@ export default function WorkspaceBlocked(): JSX.Element {
);
const handleUpdateCreditCard = useCallback(async () => {
logEvent('Workspace Blocked: User Clicked Update Credit Card', {});
void logEvent('Workspace Blocked: User Clicked Update Credit Card', {});
updateCreditCard({
url: getBaseUrl(),
@@ -117,7 +117,7 @@ export default function WorkspaceBlocked(): JSX.Element {
}, [updateCreditCard]);
const handleExtendTrial = (): void => {
logEvent('Workspace Blocked: User Clicked Extend Trial', {});
void logEvent('Workspace Blocked: User Clicked Extend Trial', {});
notifications.info({
message: t('extendTrial'),
@@ -133,7 +133,7 @@ export default function WorkspaceBlocked(): JSX.Element {
};
const handleViewBilling = (e?: React.MouseEvent): void => {
logEvent('Workspace Blocked: User Clicked View Billing', {});
void logEvent('Workspace Blocked: User Clicked View Billing', {});
safeNavigate(ROUTES.BILLING, { newTab: !!e && isModifierKeyPressed(e) });
};

View File

@@ -6,14 +6,12 @@ import { Typography } from '@signozhq/ui/typography';
import manageCreditCardApi from 'api/v1/portal/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDateWithMinutes } from 'utils/timeUtils';
import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correlation.svg';
@@ -109,15 +107,6 @@ function WorkspaceSuspended(): JSX.Element {
</Typography.Title>
<Typography.Text className="workspace-suspended__details">
{t('actionDescription')}
<br />
{t('yourDataIsSafe')}{' '}
<span className="workspace-suspended__details__highlight">
{getFormattedDateWithMinutes(
dayjs(activeLicense?.event_queue?.scheduled_at).unix() ||
Date.now(),
)}
</span>{' '}
{t('actNow')}
</Typography.Text>
</Space>
</Col>

View File

@@ -1,26 +1,9 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export type WaterfallAggregationType =
| 'span_count'
| 'execution_time_percentage'
| 'duration';
export interface WaterfallAggregationRequest {
field: TelemetryFieldKey;
aggregation: WaterfallAggregationType;
}
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
value: Record<string, number>;
}
export interface GetTraceV3PayloadProps {
export interface GetTraceV4PayloadProps {
traceId: string;
selectedSpanId: string;
uncollapsedSpans: string[];
isSelectedSpanIDUnCollapsed: boolean;
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
aggregations?: WaterfallAggregationRequest[];
}
export interface TraceDetailV3URLProps {
@@ -88,7 +71,7 @@ export interface SpanV3 {
trace_state: string;
}
export interface GetTraceV3SuccessResponse {
export interface GetTraceV4SuccessResponse {
spans: SpanV3[];
hasMissingSpans: boolean;
uncollapsedSpans: string[];
@@ -98,5 +81,4 @@ export interface GetTraceV3SuccessResponse {
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
aggregations?: WaterfallAggregationResponse[];
}

View File

@@ -24,6 +24,8 @@ interface ImportMetaEnv {
readonly VITE_TUNNEL_URL: string;
readonly VITE_TUNNEL_DOMAIN: string;
readonly VITE_DOCS_BASE_URL: string;
readonly VITE_ENVIRONMENT: string;
readonly VITE_VERSION: string;
}
interface ImportMeta {

View File

@@ -82,11 +82,20 @@ export default defineConfig(({ mode }): UserConfig => {
];
if (env.VITE_SENTRY_AUTH_TOKEN) {
// Refuse to upload sourcemaps without an explicit version.
if (!env.VITE_VERSION) {
throw new Error(
'VITE_VERSION must be set to upload sourcemaps to Sentry; refusing to upload without a matching release.',
);
}
plugins.push(
sentryVitePlugin({
authToken: env.VITE_SENTRY_AUTH_TOKEN,
org: env.VITE_SENTRY_ORG,
project: env.VITE_SENTRY_PROJECT_ID,
// Pin the sourcemap-upload release to the same value injected as
// process.env.VERSION so uploaded sourcemaps resolve. Ref: platform-pod#2393
release: { name: env.VITE_VERSION },
}),
);
}
@@ -155,6 +164,8 @@ export default defineConfig(({ mode }): UserConfig => {
'process.env.TUNNEL_URL': JSON.stringify(env.VITE_TUNNEL_URL),
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
'process.env.ENVIRONMENT': JSON.stringify(env.VITE_ENVIRONMENT),
'process.env.VERSION': JSON.stringify(env.VITE_VERSION),
},
// In production, use relative paths so assets work with any base path injected by the backend.
// In dev, use the configured base path for proper HMR and routing.

1
go.mod
View File

@@ -18,6 +18,7 @@ require (
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dustin/go-humanize v1.0.1
github.com/emersion/go-smtp v0.24.0
github.com/evanphx/json-patch/v5 v5.9.11
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.29.2

2
go.sum
View File

@@ -311,6 +311,8 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=

View File

@@ -151,6 +151,26 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services", handler.New(
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccountServicesMetadata),
handler.OpenAPIDef{
ID: "ListAccountServicesMetadata",
Tags: []string{"cloudintegration"},
Summary: "List account services metadata",
Description: "This endpoint lists the services metadata for the specified account and cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
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/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetService),
handler.OpenAPIDef{

View File

@@ -24,9 +24,10 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
// 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
}
@@ -41,13 +42,87 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
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.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.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}/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.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
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -75,6 +75,7 @@ type Handler interface {
UpdateAccount(http.ResponseWriter, *http.Request)
DisconnectAccount(http.ResponseWriter, *http.Request)
ListServicesMetadata(http.ResponseWriter, *http.Request)
ListAccountServicesMetadata(http.ResponseWriter, *http.Request)
GetService(http.ResponseWriter, *http.Request)
UpdateService(http.ResponseWriter, *http.Request)
AgentCheckIn(http.ResponseWriter, *http.Request)

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b27f1ad0-7d11-4247-9da3-91bce6211f32" x1="8.798" y1="8.703" x2="14.683" y2="8.703" gradientUnits="userSpaceOnUse"><stop offset="0.001" stop-color="#773adc"/><stop offset="1" stop-color="#552f99"/></linearGradient><linearGradient id="b2f92112-4ca9-4b17-a019-c9f26c1a4a8f" x1="5.764" y1="3.777" x2="5.764" y2="13.78" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#a67af4"/><stop offset="0.999" stop-color="#773adc"/></linearGradient></defs><g id="b8a0486a-5501-4d92-b540-a766c4b3b548"><g><g><g><path d="M16.932,11.578a8.448,8.448,0,0,1-7.95,5.59,8.15,8.15,0,0,1-2.33-.33,2.133,2.133,0,0,0,.18-.83c.01,0,.03.01.04.01a7.422,7.422,0,0,0,2.11.3,7.646,7.646,0,0,0,6.85-4.28l.01-.01Z" fill="#32bedd"/><path d="M3.582,14.068a2.025,2.025,0,0,0-.64.56,8.6,8.6,0,0,1-1.67-2.44l1.04.23v.26a.6.6,0,0,0,.47.59l.14.03a6.136,6.136,0,0,0,.62.73Z" fill="#32bedd"/><path d="M12.352.958a2.28,2.28,0,0,0-.27.81c-.02-.01-.05-.02-.07-.03a7.479,7.479,0,0,0-3.03-.63,7.643,7.643,0,0,0-5.9,2.8l-.29.06a.6.6,0,0,0-.48.58v.46l-1.02.19A8.454,8.454,0,0,1,8.982.268,8.6,8.6,0,0,1,12.352.958Z" fill="#32bedd"/><path d="M16.872,5.7l-1.09-.38a6.6,6.6,0,0,0-.72-1.16c-.02-.03-.04-.05-.05-.07a2.083,2.083,0,0,0,.72-.45A7.81,7.81,0,0,1,16.872,5.7Z" fill="#32bedd"/><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="#fff"/><g><g id="e918f286-5032-4942-ad29-ea17e6f1cc90"><path d="M1.1,5.668l1.21-.23v6.55l-1.23-.27-.99-.22a.111.111,0,0,1-.09-.12v-5.4a.12.12,0,0,1,.09-.12Z" fill="#a67af4"/></g><g><g id="a47a99dd-4d47-4c70-8c42-c5ac274ce496"><g><path d="M10.072,11.908l2.54.56L8.672,14.1c-.02,0-.03.01-.05.01a.154.154,0,0,1-.15-.15V3.448a.154.154,0,0,1,.15-.15.09.09,0,0,1,.05.01l4.46,1.56-3.05.57a.565.565,0,0,0-.44.54v5.4A.537.537,0,0,0,10.072,11.908Z" fill="url(#b27f1ad0-7d11-4247-9da3-91bce6211f32)"/><path d="M8.586,3.3,2.878,4.378a.177.177,0,0,0-.14.175V12.68a.177.177,0,0,0,.137.174L8.581,14.1a.176.176,0,0,0,.21-.174V3.478A.175.175,0,0,0,8.619,3.3Z" fill="url(#b2f92112-4ca9-4b17-a019-c9f26c1a4a8f)"/></g></g><polygon points="5.948 4.921 5.948 12.483 7.934 12.814 7.934 4.564 5.948 4.921" fill="#b796f9" opacity="0.5"/><polygon points="3.509 5.329 3.509 11.954 5.238 12.317 5.238 5.031 3.509 5.329" fill="#b796f9" opacity="0.5"/></g></g></g><path d="M16,2.048a1.755,1.755,0,1,1-1.76-1.76A1.756,1.756,0,0,1,16,2.048Z" fill="#32bedd"/><circle cx="4.65" cy="15.973" r="1.759" fill="#32bedd"/></g><path d="M18,6.689v3.844a.222.222,0,0,1-.133.2l-.766.316-3.07,1.268-.011,0a.126.126,0,0,1-.038,0,.1.1,0,0,1-.1-.1V5.234a.1.1,0,0,1,.054-.088l0,0,.019,0a.031.031,0,0,1,.019,0,.055.055,0,0,1,.034.008l.011,0,.012,0L17.05,6.2l.8.282A.213.213,0,0,1,18,6.689Z" fill="#773adc"/><path d="M13.959,5.14l-3.8.715a.118.118,0,0,0-.093.117v5.409a.118.118,0,0,0,.091.116l3.8.831a.115.115,0,0,0,.137-.09.109.109,0,0,0,0-.026V5.256a.117.117,0,0,0-.115-.118A.082.082,0,0,0,13.959,5.14Z" fill="#a67af4"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,263 +0,0 @@
{
"id": "containerapp",
"title": "Container App",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_rxbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_rxbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_txbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_restartcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_replicas_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_cpupercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_maximum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_minimum",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_memorypercentage_total",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_usagenanocores_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_workingsetbytes_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_coresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_maximum",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totalcoresquotaused_minimum",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.App",
"resourceType": "containerApps",
"metrics": {},
"logs": {
"categoryGroups": ["ContainerAppConsoleLogs", "ContainerAppSystemLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Container App Overview",
"description": "Overview of Container App metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,7 +0,0 @@
### Monitor Container Apps with SigNoz
Collect key Container App metrics and view them with an out of the box dashboard.
To collect logs, you need to make sure that you have chosen "Azure Monitor" as the logging option for Container's App Environment.
Note: This integration ingests logs for only "ContainerAppConsoleLogs" and "ContainerAppSystemLogs" diagnostic settings categories.

View File

@@ -276,6 +276,44 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
}
func (handler *handler) ListAccountServicesMetadata(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
provider, err := cloudintegrationtypes.NewCloudProvider(mux.Vars(r)["cloud_provider"])
if err != nil {
render.Error(rw, err)
return
}
accountID, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
// check if integration account exists and is not removed.
_, err = handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), accountID, provider)
if err != nil {
render.Error(rw, err)
return
}
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, accountID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableServicesMetadata(services))
}
func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -440,4 +478,3 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
}

View File

@@ -60,6 +60,12 @@ type Module interface {
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -89,4 +95,12 @@ type Handler interface {
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
}

View File

@@ -153,7 +153,7 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewUpdate().
Model(storableDashboard).
WherePK().

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -72,3 +73,135 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
orgID,
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}

View File

@@ -55,3 +55,97 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
if err != nil {
return err
}
err = existing.Update(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(*updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
}

View File

@@ -86,11 +86,12 @@ func New(
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Normalize Start/End to ms. UnmarshalJSON covers HTTP requests; callers
// that build the request programmatically skip it, so this is the catch-all
// (idempotent for the already-normalized path).
if err := req.Normalize(); err != nil {
return nil, err
}
tmplVars := req.Variables
if tmplVars == nil {
@@ -427,10 +428,12 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Catch-all normalization for programmatic callers (see QueryRange). End is
// 0 here for the open-ended stream, which Normalize leaves untouched.
if err := req.Normalize(); err != nil {
client.Error <- err
return
}
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -33,28 +33,6 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,51 +60,3 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -18,14 +18,16 @@ var (
type StorableIntegrationDashboard struct {
bun.BaseModel `bun:"table:integration_dashboard"`
ID string `bun:"id,pk,type:text"`
DashboardID string `bun:"dashboard_id,type:text"`
Provider IntegrationDashboardProviderType `bun:"provider,type:text"`
Slug string `bun:"slug,type:text"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
ID string `json:"id" bun:"id,pk,type:text" required:"true"`
DashboardID string `json:"dashboardId" bun:"dashboard_id,type:text" required:"true"`
Provider IntegrationDashboardProviderType `json:"provider" bun:"provider,type:text" required:"true"`
Slug string `json:"slug" bun:"slug,type:text" required:"true"`
CreatedAt time.Time `json:"createdAt" bun:"created_at" required:"true"`
UpdatedAt time.Time `json:"updatedAt" bun:"updated_at" required:"true"`
}
type IntegrationDashboard = StorableIntegrationDashboard
func NewStorableIntegrationDashboard(dashboardID string, provider IntegrationDashboardProviderType, slug string) *StorableIntegrationDashboard {
now := time.Now()
return &StorableIntegrationDashboard{

View File

@@ -50,10 +50,18 @@ type ListServicesMetadataParams struct {
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
ServiceDefinition
ServiceDefinitionMetadata
Overview string `json:"overview" required:"true"` // markdown
ServiceAssets ServiceAssets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supportedSignals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
}
type ServiceAssets struct {
Dashboards []*ServiceDashboard `json:"dashboards" required:"true" nullable:"false"`
}
type GetServiceParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
@@ -121,6 +129,12 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
type ServiceDashboard struct {
Title string `json:"title" required:"true"`
Description string `json:"description" required:"true"`
IntegrationDashboard *IntegrationDashboard `json:"integrationDashboard,omitempty" required:"false"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, provider CloudProviderType, config *ServiceConfig) (*CloudIntegrationService, error) {
switch provider {
case CloudProviderTypeAWS:
@@ -164,11 +178,41 @@ func NewServiceMetadata(definition ServiceDefinition, enabled bool) *ServiceMeta
}
}
func NewService(def ServiceDefinition, storableService *CloudIntegrationService) *Service {
return &Service{
ServiceDefinition: def,
CloudIntegrationService: storableService,
func NewService(provider CloudProviderType, def *ServiceDefinition, integrationService *CloudIntegrationService, integrationDashboards []*StorableIntegrationDashboard) *Service {
service := &Service{
ServiceDefinitionMetadata: def.ServiceDefinitionMetadata,
Overview: def.Overview,
SupportedSignals: def.SupportedSignals,
DataCollected: def.DataCollected,
CloudIntegrationService: integrationService,
ServiceAssets: ServiceAssets{Dashboards: make([]*ServiceDashboard, 0, len(def.Assets.Dashboards))},
}
integrationDashboardsMap := make(map[string]*IntegrationDashboard)
for _, d := range integrationDashboards {
integrationDashboardsMap[d.Slug] = d
}
for _, d := range def.Assets.Dashboards {
dashboard := &ServiceDashboard{
Title: d.Title,
Description: d.Description,
}
if integrationService != nil {
slug := CloudIntegrationDashboardSlug(provider, integrationService.Type, d.ID)
if integrationDashboard, exists := integrationDashboardsMap[slug]; exists {
if integrationDashboard != nil {
dashboard.IntegrationDashboard = integrationDashboard
}
}
}
service.ServiceAssets.Dashboards = append(service.ServiceAssets.Dashboards, dashboard)
}
return service
}
func NewGettableServicesMetadata(services []*ServiceMetadata) *GettableServicesMetadata {

View File

@@ -27,7 +27,6 @@ var (
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
)
func (ServiceID) Enum() []any {
@@ -47,7 +46,6 @@ func (ServiceID) Enum() []any {
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
}
}
@@ -71,7 +69,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
AzureServiceContainerApp,
},
}

View File

@@ -21,6 +21,7 @@ var (
ErrCodeDashboardInvalidWidgetQuery = errors.MustNewCode("dashboard_invalid_widget_query")
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
)
type StorableDashboard struct {

View File

@@ -62,6 +62,54 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) CanUpdate() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
if updatable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
}
d.DashboardV2MetadataBase = updatable.DashboardV2MetadataBase
d.Tags = resolvedTags
d.Spec = updatable.Spec
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
if d.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
}
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`
@@ -126,7 +174,7 @@ func (p *PostableDashboardV2) Validate() error {
if err := p.validateName(); err != nil {
return err
}
if err := p.validateTags(); err != nil {
if err := validateDashboardTags(p.Tags); err != nil {
return err
}
return p.Spec.Validate()
@@ -193,11 +241,11 @@ func generateDashboardName(displayName string) string {
return prefix + "-" + string(suffix)
}
func (p *PostableDashboardV2) validateTags() error {
if len(p.Tags) > MaxTagsPerDashboard {
func validateDashboardTags(tags []tagtypes.PostableTag) error {
if len(tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range p.Tags {
for _, tag := range tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
@@ -263,6 +311,57 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat
type StorableDashboardV2Metadata = DashboardV2MetadataBase
// ════════════════════════════════════════════════════════════════════════
// Updatable
// ════════════════════════════════════════════════════════════════════════
type UpdatableDashboardV2 struct {
DashboardV2MetadataBase
Name string `json:"name" required:"true"`
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias UpdatableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdatableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}
return u.Validate()
}
func (u *UpdatableDashboardV2) Validate() error {
if u.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "schemaVersion must be %q, got %q", SchemaVersion, u.SchemaVersion)
}
if err := validateDashboardName(u.Name); err != nil {
return err
}
if err := validateDashboardTags(u.Tags); err != nil {
return err
}
return u.Spec.Validate()
}
func (d DashboardV2) toUpdatableDashboardV2() UpdatableDashboardV2 {
return UpdatableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
Name: d.Name,
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Convertors
// ════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,83 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/swaggest/jsonschema-go"
)
// PatchableDashboardV2 is an RFC 6902 patch request.
type PatchableDashboardV2 struct {
// Ops shapes the OpenAPI schema; tagged so swaggest reflects it.
Ops []JSONPatchOperation `json:"ops"`
// patch holds the decoded payload, set by UnmarshalJSON.
patch jsonpatch.Patch
}
// PrepareJSONSchema collapses the struct's object schema into the bare ops array.
func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error {
// Called on several passes; only the one with built properties carries `ops`.
ops, ok := s.Properties["ops"]
if !ok || ops.TypeObject == nil {
return nil
}
*s = *ops.TypeObject
return nil
}
type JSONPatchOperation struct {
Op PatchOp `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
// `value` is required for add/replace/test.
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
// `from` is required for move/copy.
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PatchOp covers the six RFC 6902 JSON Patch verbs.
type PatchOp struct{ valuer.String }
var (
PatchOpAdd = PatchOp{valuer.NewString("add")}
PatchOpRemove = PatchOp{valuer.NewString("remove")}
PatchOpReplace = PatchOp{valuer.NewString("replace")}
PatchOpMove = PatchOp{valuer.NewString("move")}
PatchOpCopy = PatchOp{valuer.NewString("copy")}
PatchOpTest = PatchOp{valuer.NewString("test")}
)
func (PatchOp) Enum() []any {
return []any{PatchOpAdd, PatchOpRemove, PatchOpReplace, PatchOpMove, PatchOpCopy, PatchOpTest}
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
patch, err := jsonpatch.DecodePatch(data)
if err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
}
if err := json.Unmarshal(data, &p.Ops); err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
}
p.patch = patch
return nil
}
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV2, error) {
existingAsUpdatable := existing.toUpdatableDashboardV2()
raw, err := json.Marshal(existingAsUpdatable)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
if err != nil {
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
}
out := &UpdatableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}

View File

@@ -0,0 +1,569 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// basePostableJSON is the postable shape of a small but realistic v2
// dashboard used as the base document for patch tests. Each panel carries
// one builder query in the same shape production dashboards use
// (aggregations, filter, groupBy populated), and the dashboard has one
// variable — the variable is not patched in any test here, that's
// covered in a separate variable-focused suite.
const basePostableJSON = `{
"schemaVersion": "v6",
"name": "service-overview",
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}],
"filter": {"expression": "service.name IN $service"},
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
}}}
}
]
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_latency_count",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {"title": "Row 1"},
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]
}
}
],
"duration": "1h"
}
}`
func TestPatchableDashboardV2_Apply(t *testing.T) {
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
// JSON, applies the patch, and unmarshals the result into a fresh
// struct. Sharing one base across subtests is safe.
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},
}
decode := func(t *testing.T, body string) PatchableDashboardV2 {
t.Helper()
var patch PatchableDashboardV2
require.NoError(t, json.Unmarshal([]byte(body), &patch))
return patch
}
// jsonOf marshals the patched dashboard back to JSON so subtests can
// assert on field values without reaching into the typed plugin specs.
jsonOf := func(t *testing.T, out *UpdatableDashboardV2) string {
t.Helper()
raw, err := json.Marshal(out)
require.NoError(t, err)
return string(raw)
}
// ─────────────────────────────────────────────────────────────────
// Successful patches
// ─────────────────────────────────────────────────────────────────
t.Run("no-op preserves all fields", func(t *testing.T) {
out, err := decode(t, `[]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.DashboardV2MetadataBase, out.DashboardV2MetadataBase)
assert.Equal(t, tagtypes.NewPostableTagsFromTags(base.Tags), out.Tags)
assert.Equal(t, base.Spec.Display.Name, out.Spec.Display.Name)
require.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels))
for k, panel := range base.Spec.Panels {
require.Contains(t, out.Spec.Panels, k)
assert.Equal(t, panel.Spec.Plugin.Kind, out.Spec.Panels[k].Spec.Plugin.Kind)
}
assert.Len(t, out.Tags, len(base.Tags))
assert.Len(t, out.Spec.Variables, len(base.Spec.Variables))
assert.Len(t, out.Spec.Layouts, len(base.Spec.Layouts))
})
t.Run("add metadata image", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/image", "value": "https://example.com/img.png"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", out.Image)
assert.Equal(t, SchemaVersion, out.SchemaVersion, "schemaVersion preserved")
})
t.Run("replace display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": "Renamed"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Renamed", out.Spec.Display.Name)
})
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
// existing value rather than erroring — same effect as `replace`.
t.Run("add overwrites existing display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/display/name", "value": "Overwritten"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Overwritten", out.Spec.Display.Name)
})
t.Run("add data refreshInterval", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/refreshInterval", "value": "30s"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "30s", string(out.Spec.RefreshInterval))
})
t.Run("add panel leaves others untouched", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
assert.Contains(t, out.Spec.Panels, "p3")
// Plugin specs round-trip through MarshalJSON which resolves defaults
// (e.g. timePreference → "global_time"), so compare the serialized
// shape rather than the in-memory structs to skip that normalization.
for _, id := range []string{"p1", "p2"} {
wantJSON, err := json.Marshal(base.Spec.Panels[id])
require.NoError(t, err)
gotJSON, err := json.Marshal(out.Spec.Panels[id])
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(gotJSON), "panel %s untouched", id)
}
})
t.Run("replace single panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Spec.Panels["p2"].Spec.Plugin.Kind)
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Spec.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
})
// Removing a panel realistically also drops its layout item — exercise
// the multi-op shape the UI sends.
t.Run("remove panel and its layout item", func(t *testing.T) {
out, err := decode(t, `[
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 1)
assert.Contains(t, out.Spec.Panels, "p1")
assert.NotContains(t, out.Spec.Panels, "p2")
raw := jsonOf(t, out)
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
})
// The headline use case: edit a single field of a single query inside
// one panel without re-sending any other part of the dashboard.
t.Run("rename single query inside panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0/spec/plugin/spec/name",
"value": "renamed"
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
})
// Replace a query at a specific index — swaps query "A" out for "B"
// without re-sending the rest of the panel.
t.Run("replace query at index", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_db_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"name":"B"`)
assert.NotContains(t, raw, `"name":"A"`)
})
// ─────────────────────────────────────────────────────────────────
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
require.NoError(t, err)
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
})
// Composing add-panel + add-layout-item is the realistic shape of the
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
t.Run("add panel and corresponding layout item", func(t *testing.T) {
out, err := decode(t, `[
{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
})
t.Run("append tag", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 3)
assert.Equal(t, "env", out.Tags[2].Key)
assert.Equal(t, "staging", out.Tags[2].Value)
})
t.Run("append tag when none exist", func(t *testing.T) {
noTagsBase := &DashboardV2{
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Tags: nil,
Spec: base.Spec,
}
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
require.NoError(t, err)
require.Len(t, out.Tags, 1)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "new", out.Tags[0].Value)
})
t.Run("replace tag value", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 2)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "beta", out.Tags[0].Value)
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
for _, tag := range out.Tags {
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
}
})
t.Run("multiple ops applied in order", func(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Multi-step", out.Spec.Display.Name)
assert.Len(t, out.Spec.Panels, 1)
assert.Len(t, out.Tags, 3)
})
// `test` is an RFC 6902 precondition op: aborts the patch if the value
// at the path doesn't equal the supplied value. Used for optimistic
// concurrency. Here it matches, so the subsequent ops apply.
t.Run("test op passes", func(t *testing.T) {
out, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Service overview"},
{"op": "replace", "path": "/spec/display/name", "value": "Confirmed"}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Confirmed", out.Spec.Display.Name)
})
// ─────────────────────────────────────────────────────────────────
// Failure cases
// ─────────────────────────────────────────────────────────────────
t.Run("decode rejects non-array body", func(t *testing.T) {
var patch PatchableDashboardV2
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
require.Error(t, err)
})
t.Run("decode rejects malformed JSON", func(t *testing.T) {
var patch PatchableDashboardV2
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
// than the InvalidInput-classified wrap.
err := json.Unmarshal([]byte(`not json`), &patch)
require.Error(t, err)
})
// `test` precondition fails — the whole patch is rejected, including
// the subsequent replace.
t.Run("test op failure rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Wrong"},
{"op": "replace", "path": "/spec/display/name", "value": "Should not apply"}
]`).Apply(base)
require.Error(t, err)
})
// Lenient apply (AllowMissingPathOnRemove): removing a path that doesn't
// exist is a no-op rather than an error, so removes are idempotent.
t.Run("remove at missing path is a no-op", func(t *testing.T) {
out, err := decode(t, `[{"op": "remove", "path": "/spec/panels/does-not-exist"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels), "existing panels untouched")
})
t.Run("remove schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/schemaVersion"}]`).Apply(base)
require.Error(t, err)
})
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/schemaVersion", "value": "v5"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), SchemaVersion)
})
t.Run("empty display name defaults to dashboard name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": ""}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.Name, out.Spec.Display.Name, "empty display.name should default from name")
})
t.Run("unknown top-level field rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "bogus")
})
t.Run("invalid panel kind rejected", func(t *testing.T) {
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "NotAPanel")
})
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
// PromQLQuery is not allowed on ListPanel — verify the cross-check
// in Validate still runs after a patch.
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
require.Error(t, err)
})
t.Run("removing the only query rejected", func(t *testing.T) {
// Validate requires exactly one query per panel — leaving zero is rejected.
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/p2/spec/queries/0"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("two direct queries rejected", func(t *testing.T) {
// Validate requires exactly one query per panel. To display multiple
// data sources in one panel, wrap them in a CompositeQuery (see the
// "replace query with composite" subtest below).
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "metrics",
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B", "signal": "metrics",
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}}
]
}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("too many tags rejected", func(t *testing.T) {
// Base already has 2 tags; add 9 more to exceed MaxTagsPerDashboard (10).
_, err := decode(t, `[
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "5"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "6"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "7"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "8"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "9"}}
]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at most")
})
}

View File

@@ -12,6 +12,13 @@ import (
"github.com/swaggest/jsonschema-go"
)
const (
// minEpochMs and maxEpochMs bound a plausible ms timestamp to
// 1990-01-01 .. 2100-01-01, used to reject malformed Start/End values.
minEpochMs uint64 = 631_152_000_000
maxEpochMs uint64 = 4_102_444_800_000
)
type QueryEnvelope struct {
// Type is the type of the query.
Type QueryType `json:"type"` // "builder_query" | "builder_formula" | "builder_sub_query" | "builder_join" | "promql" | "clickhouse_sql"
@@ -549,7 +556,23 @@ func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
return false
}
// UnmarshalJSON implements custom JSON unmarshaling to disallow unknown fields.
// Normalize coerces Start and End to epoch milliseconds, inferring the source
// resolution (s/ms/µs/ns) from each value's magnitude, and rejects non-zero
// values outside the plausible 1990-2100 range. Lets downstream consumers
// assume ms regardless of what the caller sent.
func (r *QueryRangeRequest) Normalize() error {
start, err := toMilliSecs(r.Start)
if err != nil {
return err
}
end, err := toMilliSecs(r.End)
if err != nil {
return err
}
r.Start, r.End = start, end
return nil
}
func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Define a type alias to avoid infinite recursion
type Alias QueryRangeRequest
@@ -609,6 +632,11 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce Start/End to ms (and validate) at decode time for HTTP requests.
if err := r.Normalize(); err != nil {
return err
}
return nil
}
@@ -662,3 +690,24 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
return canDefaultZero
}
// toMilliSecs scales an epoch to milliseconds based on its magnitude: seconds are
// scaled up, micro/nanoseconds down, milliseconds left as-is. Zero is returned
// unchanged. A non-zero result outside 1990-2100 is rejected as malformed.
func toMilliSecs(epoch uint64) (uint64, error) {
var ms uint64
switch {
case epoch < 1e12: // seconds
ms = epoch * 1_000
case epoch < 1e15: // milliseconds
ms = epoch
case epoch < 1e18: // microseconds
ms = epoch / 1_000
default: // nanoseconds
ms = epoch / 1_000_000
}
if epoch != 0 && (ms < minEpochMs || ms > maxEpochMs) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "timestamp %d is outside the supported range (1990-2100)", epoch)
}
return ms, nil
}

View File

@@ -1903,3 +1903,70 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestQueryRangeRequest_Normalize(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantStart uint64
wantEnd uint64
wantErr bool
}{
{
name: "seconds are scaled up to ms",
start: 1672531200, // 2023-01-01 in seconds
end: 1716638400, // 2024-05-25 in seconds
wantStart: 1672531200000, // * 10^3
wantEnd: 1716638400000,
},
{
name: "milliseconds pass through unchanged",
start: 1672531200000,
end: 1716638400000,
wantStart: 1672531200000,
wantEnd: 1716638400000,
},
{
name: "microseconds are scaled down to ms",
start: 1672531200000000, // µs
end: 1716638400000000,
wantStart: 1672531200000, // / 10^3
wantEnd: 1716638400000,
},
{
name: "nanoseconds are scaled down to ms",
start: 1672531200000000000, // ns
end: 1716638400000000000,
wantStart: 1672531200000, // / 10^6
wantEnd: 1716638400000,
},
{
name: "zero end (open-ended stream) is left untouched",
start: 1672531200000,
end: 0,
wantStart: 1672531200000,
wantEnd: 0,
},
{
name: "out-of-range timestamp is rejected",
start: 5_000_000_000_000, // ~year 2128 in ms, beyond the 2100 bound
end: 5_000_000_000_000,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{Start: tt.start, End: tt.end}
err := r.Normalize()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantStart, r.Start)
assert.Equal(t, tt.wantEnd, r.End)
})
}
}

View File

@@ -86,6 +86,49 @@ def test_list_services_with_account(
assert svc["enabled"] is False, f"Service {svc['id']} should be disabled before any config is set"
EC2_SERVICE_ID = "ec2"
def test_list_account_services(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert list_response.status_code == HTTPStatus.OK, f"Expected 200, got {list_response.status_code}"
data = list_response.json()["data"]
assert "services" in data, "Response should contain 'services' field"
assert isinstance(data["services"], list), "services should be a list"
assert len(data["services"]) > 0, "services list should be non-empty"
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument