Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
59b6f6e851 fix: checkbox issue 2026-06-03 00:30:57 +05:30
279 changed files with 1693 additions and 13660 deletions

View File

@@ -1,17 +0,0 @@
# Title
## Status
What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.?
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?

View File

@@ -1,30 +0,0 @@
# Recording Architecture Decisions
## Status
Proposed
## Context
At SigNoz, architecture decisions are stored across internal communication channels, Notion, GitHub Issues, or directly
in PR comments and code.
When we need to understand the reasoning behind a large change or a specific decision in the codebase, finding it is
difficult because we must search across many different places.
## Decision
Instead of storing architecture decisions outside this codebase, we will use the structure proposed
by [ADR](https://github.com/architecture-decision-record/architecture-decision-record).
This is the first decision being stored, alongside [0000-template.md](./0000-template.md) which serves as the template
for new decisions.
The current template is basic and inspired
by [Michael Nygard's template](https://github.com/architecture-decision-record/architecture-decision-record/blob/main/locales/en/templates/decision-record-template-by-michael-nygard/index.md).
Over time, we can improve or change it to better fit our needs.
## Consequences
We can still store internal discussions or private matters in our internal communication channels. However, with this
approach, we have a single place to look for decisions about the codebase.

View File

@@ -1,61 +0,0 @@
# Migrate from ESLint to Oxlint
## Status
Accepted
## Context
Current implementation (02/17/2025) uses ESLint v7 (latest is v10):
- **Dependencies**: 12 (without eslint itself)
- **Execution time**: 113s locally, 185s on CI
- **Memory usage**: 3472mb
Our pain with Eslint is caused by the integration of it when create a commit, causing the git commit to take multiple
seconds instead of being instant or feel instant.
### Requirements for new tooling
- Fast execution (primary goal)
- Community/plugin support
- IDE support (Cursor/VSCode, JetBrains)
- Fewer dependencies (install speed, reduced threat vector)
### Alternatives evaluated
| Feature | ESLint | Biome | Oxlint |
|-------------------|--------|-------|--------|
| Lint Speed | 195s | 13s | 6s |
| Format Speed | 20s | 0.5s | 1.3s |
| Type-aware | Yes | Yes | Yes* |
| Plugin Support | Yes | No** | Yes |
| Dependencies | 12 | 1 | 6 |
| Replaces Prettier | No | Yes | Yes*** |
\* Type-aware via tsgolint (uses Go-based TypeScript compiler)
\** Biome uses GritQL for custom rules
\*** Via oxfmt
## Decision
Migrate to Oxlint.
References:
- https://app.notion.com/p/signoz/Linting-Formatting-30cfcc6bcd1980a7bb47f04a41e67c21#30cfcc6bcd1980d28398c2fd8bcb53fb
### Rationale
1. **VoidZero ecosystem alignment**: Oxlint is part of Oxc project under VoidZero initiative (includes Vite, Vitest,
Rolldown). Future migration to Vite benefits from consistent tooling.
2. **Performance**: Fastest option at 6s vs 195s (32x improvement). CI time: best among alternatives.
3. **JS plugin support**: Custom rules possible without learning GritQL. Example: `signoz/no-zustand-getstate-in-hooks`
rule already implemented.
## Consequences
- **Development speed**: 32x faster linting (6s vs 195s)
- **CI time**: Significant reduction in pipeline duration
- **Memory usage**: Lower footprint than ESLint

View File

@@ -1,63 +0,0 @@
# Migrate from Yarn to pnpm
## Status
Accepted
## Context
Yarn install time is one of the slowest part of the CI, around 108s locally or 120s on CI. Also, we are using v1 that is
on maintenance mode since 2020, and [will eventually reach EOL](https://github.com/yarnpkg/yarn/issues/9062).
### Requirements
- Faster install times
- Better CI performance
- Reduced attack surface for supply chain attacks
## Decision
We decided to migrate to pnpm mostly due to the usage of this package manager already by
our [Component Library](https://github.com/SigNoz/components). We had good experience and the performance was great.
### Performance improvements
| CI Job | Yarn | pnpm | Diff |
|-----------|--------|-------|----------|
| tsc/js | 2m56s | 1m21s | -95s |
| test/js | 10m30s | 8m20s | -130s |
| fmt/js | 2m25s | 33s | -112s |
| lint/js | 2m56s | 44s | -130s |
| authz | 11m9s | 9m24s | -105s |
| openapi | 2m41s | 1m7s | -94s |
| **Total** | | | **-11m** |
Install time: 108s → 16s (5.8x faster)
### Security hardening
Added `pnpm-workspace.yaml` with minimum release age:
```yaml
trustPolicy: no-downgrade
minimumReleaseAge: 2880 # 2d
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- '@signozhq/*'
blockExoticSubdeps: true
```
Prevents installing packages released less than 2 days ago — mitigates supply chain attacks where malicious code is
pushed and quickly removed.
## Consequences
- Replace `yarn` commands with `pnpm` equivalents
- `yarn add``pnpm add`
- `yarn install``pnpm install`
- `yarn run``pnpm run` (or just `pnpm <script>`)
### References
- PR #11158: https://github.com/SigNoz/signoz/pull/11158
- PR #11274: https://github.com/SigNoz/signoz/pull/11274

4
.github/CODEOWNERS vendored
View File

@@ -188,7 +188,3 @@ 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,8 +69,6 @@ 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,8 +70,6 @@ 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,8 +35,6 @@ 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

@@ -52,7 +52,6 @@ jobs:
- rootuser
- serviceaccount
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
sqlstore-provider:
- postgres

View File

@@ -432,7 +432,7 @@ cloudintegration:
version: v0.0.8
##################### Trace Detail #####################
traces:
tracedetail:
waterfall:
# Number of spans returned per request when the trace is too large to show all at once.
span_page_size: 500

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.127.0
image: signoz/signoz:v0.126.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.127.0
image: signoz/signoz:v0.126.1
ports:
- "8080:8080" # signoz port
volumes:
@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.5
image: signoz/signoz-otel-collector:v0.144.4
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.127.0}
image: signoz/signoz:${VERSION:-v0.126.1}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.127.0}
image: signoz/signoz:${VERSION:-v0.126.1}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -870,6 +870,14 @@ components:
- timestampMillis
- data
type: object
CloudintegrationtypesAssets:
properties:
dashboards:
items:
$ref: '#/components/schemas/CloudintegrationtypesDashboard'
nullable: true
type: array
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
deploymentRegion:
@@ -1017,6 +1025,17 @@ 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:
@@ -1190,7 +1209,7 @@ components:
CloudintegrationtypesService:
properties:
assets:
$ref: '#/components/schemas/CloudintegrationtypesServiceAssets'
$ref: '#/components/schemas/CloudintegrationtypesAssets'
cloudIntegrationService:
$ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService'
dataCollected:
@@ -1203,6 +1222,8 @@ components:
type: string
supportedSignals:
$ref: '#/components/schemas/CloudintegrationtypesSupportedSignals'
telemetryCollectionStrategy:
$ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy'
title:
type: string
required:
@@ -1213,17 +1234,9 @@ 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:
@@ -1231,18 +1244,6 @@ 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
@@ -1277,30 +1278,6 @@ 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:
@@ -1308,6 +1285,13 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesTelemetryCollectionStrategy:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
@@ -2396,26 +2380,6 @@ components:
repeatVariable:
type: string
type: object
DashboardLink:
properties:
name:
type: string
renderVariables:
type: boolean
targetBlank:
type: boolean
tooltip:
type: string
url:
type: string
type: object
DashboardPanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
DashboardTextVariableSpec:
properties:
constant:
@@ -2546,7 +2510,7 @@ components:
type: array
links:
items:
$ref: '#/components/schemas/DashboardLink'
$ref: '#/components/schemas/V1Link'
type: array
panels:
additionalProperties:
@@ -2681,34 +2645,10 @@ 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/DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec'
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpec:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec:
properties:
kind:
enum:
@@ -2791,7 +2731,7 @@ components:
DashboardtypesPanel:
properties:
kind:
$ref: '#/components/schemas/DashboardtypesPanelKind'
type: string
spec:
$ref: '#/components/schemas/DashboardtypesPanelSpec'
type: object
@@ -2802,10 +2742,6 @@ components:
unit:
type: string
type: object
DashboardtypesPanelKind:
enum:
- Panel
type: string
DashboardtypesPanelPlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -2912,10 +2848,10 @@ components:
DashboardtypesPanelSpec:
properties:
display:
$ref: '#/components/schemas/DashboardPanelDisplay'
$ref: '#/components/schemas/V1PanelDisplay'
links:
items:
$ref: '#/components/schemas/DashboardLink'
$ref: '#/components/schemas/V1Link'
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
@@ -2924,20 +2860,6 @@ 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:
@@ -2988,7 +2910,7 @@ components:
DashboardtypesQuery:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
type: string
spec:
$ref: '#/components/schemas/DashboardtypesQuerySpec'
type: object
@@ -3225,27 +3147,6 @@ 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:
@@ -3256,8 +3157,8 @@ components:
DashboardtypesVariable:
oneOf:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpec'
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec'
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpec:
- $ref: '#/components/schemas/DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpec'
DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpec:
properties:
kind:
enum:
@@ -7275,6 +7176,26 @@ components:
required:
- id
type: object
V1Link:
properties:
name:
type: string
renderVariables:
type: boolean
targetBlank:
type: boolean
tooltip:
type: string
url:
type: string
type: object
V1PanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
VariableDefaultValue:
type: object
VariableDisplay:
@@ -8107,139 +8028,7 @@ 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}:
get:
deprecated: false
description: This endpoint gets a service and its configuration for the specified
cloud integration account
operationId: GetAccountService
parameters:
- in: path
name: cloud_provider
required: true
schema:
type: string
- in: path
name: id
required: true
schema:
type: string
- in: path
name: service_id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/CloudintegrationtypesService'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get service for account
tags:
- cloudintegration
put:
deprecated: false
description: This endpoint updates a service for the specified cloud provider
@@ -13035,12 +12824,6 @@ paths:
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -13093,12 +12876,6 @@ paths:
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -13111,12 +12888,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -13131,262 +12902,6 @@ 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,32 +355,26 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
var integrationService *cloudintegrationtypes.CloudIntegrationService
if cloudIntegrationID.IsZero() {
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
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
}
}
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
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
}
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
@@ -589,3 +583,20 @@ 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,18 +221,6 @@ 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

@@ -0,0 +1,28 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
newTab?: boolean;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
interface UseSafeNavigateReturn {
safeNavigate: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>;
}
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
safeNavigate: jest.fn(
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
) as jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>,
});

View File

@@ -1,8 +1,6 @@
import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH =
'<rootDir>/src/__tests__/safeNavigateMock.ts';
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
silent: true,
@@ -24,8 +22,6 @@ const config: Config.InitialOptions = {
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
'^constants/env$': '<rootDir>/__mocks__/env.ts',
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',

View File

@@ -351,18 +351,19 @@ function App(): JSX.Element {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: process.env.ENVIRONMENT,
release: process.env.VERSION,
environment: 'production',
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,
}),
],
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
// 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
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

@@ -1,11 +0,0 @@
// Shared mock for `api/common/logEvent`.
// Wired into jest.config.ts moduleNameMapper, so any import of
// `api/common/logEvent` in test code resolves to this file.
// Tests can import `logEventMock` to assert analytics calls — Jest's
// `clearMocks: true` resets call history between tests.
export const logEventMock: jest.MockedFunction<
(eventName: string, attributes?: Record<string, unknown>) => void
> = jest.fn();
export default logEventMock;

View File

@@ -1,29 +0,0 @@
// Shared mock for `hooks/useSafeNavigate`.
// Wired into jest.config.ts moduleNameMapper, so any import of
// `hooks/useSafeNavigate` in test code resolves to this file.
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
// `clearMocks: true` resets call history between tests.
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
newTab?: boolean;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
export const safeNavigateMock: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
> = jest.fn();
export const useSafeNavigate = (): {
safeNavigate: typeof safeNavigateMock;
} => ({
safeNavigate: safeNavigateMock,
});

View File

@@ -3,36 +3,13 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface DayBreakdownEntry {
timestamp: number;
total: number;
quantity: number;
count: number;
size: number;
}
export interface TierEntry {
quantity: number;
unitPrice: number;
tierCost: number;
}
export interface BreakdownEntry {
type: string;
unit: string;
dayWiseBreakdown: {
breakdown: DayBreakdownEntry[];
};
tiers?: TierEntry[];
}
export interface UsageResponsePayloadProps {
billingPeriodStart: number;
billingPeriodEnd: number;
billingPeriodStart: Date;
billingPeriodEnd: Date;
details: {
total: number;
baseFee: number;
breakdown: BreakdownEntry[];
breakdown: [];
billTotal: number;
};
discount: number;

View File

@@ -31,15 +31,11 @@ import type {
DisconnectAccountPathParameters,
GetAccount200,
GetAccountPathParameters,
GetAccountService200,
GetAccountServicePathParameters,
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccountServicesMetadata200,
ListAccountServicesMetadataPathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
@@ -635,227 +631,6 @@ 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 gets a service and its configuration for the specified cloud integration account
* @summary Get service for account
*/
export const getAccountService = (
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAccountService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
method: 'GET',
signal,
});
};
export const getGetAccountServiceQueryKey = ({
cloudProvider,
id,
serviceId,
}: GetAccountServicePathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
] as const;
};
export const getGetAccountServiceQueryOptions = <
TData = Awaited<ReturnType<typeof getAccountService>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetAccountServiceQueryKey({ cloudProvider, id, serviceId });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAccountService>>
> = ({ signal }) =>
getAccountService({ cloudProvider, id, serviceId }, signal);
return {
queryKey,
queryFn,
enabled: !!(cloudProvider && id && serviceId),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAccountServiceQueryResult = NonNullable<
Awaited<ReturnType<typeof getAccountService>>
>;
export type GetAccountServiceQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service for account
*/
export function useGetAccountService<
TData = Awaited<ReturnType<typeof getAccountService>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAccountService>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAccountServiceQueryOptions(
{ cloudProvider, id, serviceId },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get service for account
*/
export const invalidateGetAccountService = async (
queryClient: QueryClient,
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAccountServiceQueryKey({ cloudProvider, id, serviceId }) },
options,
);
return queryClient;
};
/**
* This endpoint updates a service for the specified cloud provider
* @summary Update service

View File

@@ -21,10 +21,8 @@ import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
@@ -35,13 +33,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -824,360 +816,3 @@ 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,6 +2457,33 @@ 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
@@ -2839,54 +2866,6 @@ 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
@@ -2898,8 +2877,13 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
metrics?: boolean;
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesServiceDTO {
assets: CloudintegrationtypesServiceAssetsDTO;
assets: CloudintegrationtypesAssetsDTO;
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
dataCollected: CloudintegrationtypesDataCollectedDTO;
/**
@@ -2915,6 +2899,7 @@ export interface CloudintegrationtypesServiceDTO {
*/
overview: string;
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
/**
* @type string
*/
@@ -3104,40 +3089,6 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export interface DashboardLinkDTO {
/**
* @type string
*/
name?: string;
/**
* @type boolean
*/
renderVariables?: boolean;
/**
* @type boolean
*/
targetBlank?: boolean;
/**
* @type string
*/
tooltip?: string;
/**
* @type string
*/
url?: string;
}
export interface DashboardPanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface VariableDisplayDTO {
/**
* @type string
@@ -3754,10 +3705,6 @@ export interface DashboardtypesCustomVariableSpecDTO {
customValue: string;
}
export interface DashboardtypesStorableDashboardDataDTO {
[key: string]: unknown;
}
export enum DashboardtypesSourceDTO {
user = 'user',
system = 'system',
@@ -3839,9 +3786,40 @@ export type DashboardtypesDashboardSpecDTODatasources = {
[key: string]: DashboardtypesDatasourceSpecDTO;
};
export enum DashboardtypesPanelKindDTO {
Panel = 'Panel',
export interface V1PanelDisplayDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
name?: string;
}
export interface V1LinkDTO {
/**
* @type string
*/
name?: string;
/**
* @type boolean
*/
renderVariables?: boolean;
/**
* @type boolean
*/
targetBlank?: boolean;
/**
* @type string
*/
tooltip?: string;
/**
* @type string
*/
url?: string;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
}
@@ -4083,13 +4061,6 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
time_series = 'time_series',
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
}
@@ -4394,16 +4365,19 @@ export interface DashboardtypesQuerySpecDTO {
}
export interface DashboardtypesQueryDTO {
kind?: Querybuildertypesv5RequestTypeDTO;
/**
* @type string
*/
kind?: string;
spec?: DashboardtypesQuerySpecDTO;
}
export interface DashboardtypesPanelSpecDTO {
display?: DashboardPanelDisplayDTO;
display?: V1PanelDisplayDTO;
/**
* @type array
*/
links?: DashboardLinkDTO[];
links?: V1LinkDTO[];
plugin?: DashboardtypesPanelPluginDTO;
/**
* @type array
@@ -4412,7 +4386,10 @@ export interface DashboardtypesPanelSpecDTO {
}
export interface DashboardtypesPanelDTO {
kind?: DashboardtypesPanelKindDTO;
/**
* @type string
*/
kind?: string;
spec?: DashboardtypesPanelSpecDTO;
}
@@ -4426,20 +4403,20 @@ export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
export type DashboardtypesDashboardSpecDTOPanels =
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
export enum DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTOKind {
Grid = 'Grid',
}
export interface DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTO {
export interface DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTO {
/**
* @enum Grid
* @type string
*/
kind: DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind;
kind: DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTOKind;
spec: DashboardGridLayoutSpecDTO;
}
export type DashboardtypesLayoutDTO =
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTO;
DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTO;
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind {
ListVariable = 'ListVariable',
@@ -4543,21 +4520,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
spec: DashboardtypesListVariableSpecDTO;
}
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
export enum DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTOKind {
TextVariable = 'TextVariable',
}
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
export interface DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTO {
/**
* @enum TextVariable
* @type string
*/
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
kind: DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTOKind;
spec: DashboardTextVariableSpecDTO;
}
export type DashboardtypesVariableDTO =
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
| DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTO;
export interface DashboardtypesDashboardSpecDTO {
/**
@@ -4576,7 +4553,7 @@ export interface DashboardtypesDashboardSpecDTO {
/**
* @type array
*/
links?: DashboardLinkDTO[];
links?: V1LinkDTO[];
/**
* @type object,null
*/
@@ -4676,32 +4653,6 @@ 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',
@@ -4711,13 +4662,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
}
/**
* @nullable
*/
export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -4761,26 +4705,6 @@ 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
@@ -6958,6 +6882,13 @@ export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
[key: string]: Querybuildertypesv5VariableItemDTO;
};
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
time_series = 'time_series',
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
}
/**
* Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.
*/
@@ -8692,31 +8623,6 @@ 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 GetAccountServicePathParameters = {
cloudProvider: string;
id: string;
serviceId: string;
};
export type GetAccountService200 = {
data: CloudintegrationtypesServiceDTO;
/**
* @type string
*/
status: string;
};
export type UpdateServicePathParameters = {
cloudProvider: string;
id: string;
@@ -9570,34 +9476,6 @@ 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,14 +1,15 @@
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV4 = async (
props: GetTraceV4PayloadProps,
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
@@ -18,37 +19,31 @@ const getTraceV4 = async (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const response = await getWaterfallV4(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
uncollapsedSpans,
limit: 10000,
},
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// 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 };
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// V3 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;
@@ -75,4 +70,4 @@ const getTraceV4 = async (
};
};
export default getTraceV4;
export default getTraceV3;

View File

@@ -349,7 +349,7 @@ function convertV5DataByType(
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function convertV5ResponseToLegacy(
v5Response: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5>,
v5Response: SuccessResponse<MetricRangePayloadV5>,
legendMap: Record<string, string>,
formatForWeb?: boolean,
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
@@ -357,7 +357,7 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries
(params as QueryRangeRequestV5)?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {

View File

@@ -359,7 +359,8 @@ function CustomTimePickerPopoverContent({
<Clock
color={Color.BG_ROBIN_400}
className="timezone-container__clock-icon"
size={14}
height={12}
width={12}
/>
<span className="timezone__name">{timezone.name}</span>

View File

@@ -144,7 +144,10 @@ function AddedFields({
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
isRequired={requiredFields.includes(field.key as string)}
isRequired={
requiredFields.includes(field.name) ||
requiredFields.includes(field.key as string)
}
/>
))}
</SortableContext>

View File

@@ -25,7 +25,7 @@ describe('AddedFields — requiredFields', () => {
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
});
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
it('hides the Remove button for fields whose name is in requiredFields', () => {
const fields = [makeField('a'), makeField('b'), makeField('c')];
render(
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a', 'log.c']}
requiredFields={['a', 'c']}
/>,
);
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.a']}
requiredFields={['a']}
/>,
);
@@ -58,26 +58,9 @@ describe('AddedFields — requiredFields', () => {
expect(screen.getByText('b')).toBeInTheDocument();
});
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
// Two `body` fields with different contexts. requiredFields holds the
// canonical composite key only, so the attribute variant is deletable.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
/>,
);
// One Remove button: the attribute variant. log variant is locked.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
it('does not lock anything when a bare name is passed (composite key required)', () => {
// Bare `body` no longer matches — matching is composite-key only now.
it('locks all variants of a required name regardless of fieldContext', () => {
// Two `body` fields with different contexts — both should lock when
// `body` is in requiredFields.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
@@ -89,11 +72,11 @@ describe('AddedFields — requiredFields', () => {
/>,
);
// Neither variant locked → both removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
// Both 'body' variants locked → zero Remove buttons.
expect(screen.queryAllByRole('button', { name: /remove/i })).toHaveLength(0);
});
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
it('treats requiredFields as exact-name match (substring does not lock)', () => {
const fields = [makeField('body'), makeField('body_extra')];
render(
@@ -101,11 +84,30 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['body']}
/>,
);
// 'body' locked, 'body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
it('also accepts composite IDs in requiredFields (locks a specific variant)', () => {
// Two `body` fields with different contexts.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
// Composite ID — locks ONLY the log variant, attribute variant stays
// removable.
requiredFields={['log.body']}
/>,
);
// 'log.body' locked, 'log.body_extra' removable.
// One Remove button: the attribute variant. log variant is locked.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
});

View File

@@ -3,12 +3,17 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import FeedbackModal from '../FeedbackModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -30,6 +35,7 @@ jest.mock('container/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const mockHandleContactSupport = handleContactSupport as jest.Mock;
@@ -44,7 +50,6 @@ const mockLocation = {
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
logEventMock.mockReturnValue(Promise.resolve() as never);
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
@@ -111,7 +116,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
@@ -144,7 +149,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
@@ -177,7 +182,7 @@ describe('FeedbackModal', () => {
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,

View File

@@ -2,11 +2,16 @@
import { useLocation } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import HeaderRightSection from '../HeaderRightSection';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -45,6 +50,7 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
@@ -114,7 +120,7 @@ describe('HeaderRightSection', () => {
await user.click(feedbackButton!);
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
@@ -127,7 +133,7 @@ describe('HeaderRightSection', () => {
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
@@ -144,7 +150,7 @@ describe('HeaderRightSection', () => {
await user.click(announcementsButton!);
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});

View File

@@ -5,13 +5,18 @@ import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import ShareURLModal from '../ShareURLModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
@@ -48,6 +53,7 @@ Object.defineProperty(window, 'location', {
writable: true,
});
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseUrlQuery = useUrlQuery as jest.Mock;
const mockUseSelector = useSelector as jest.Mock;
@@ -119,7 +125,7 @@ describe('ShareURLModal', () => {
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});

View File

@@ -10,9 +10,9 @@ jest.mock('providers/Timezone', () => ({
}),
}));
const field = (name: string, type = ''): IField => ({
const field = (name: string): IField => ({
name,
type,
type: '',
dataType: 'string',
});
@@ -38,16 +38,18 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
useLogsTableColumns({
fields: [
field('service.name'),
field('body', 'log'),
field('body'),
field('request.id'),
field('timestamp', 'log'),
field('timestamp'),
],
fontSize: FontSize.SMALL,
}),
);
// body/timestamp appear where the caller placed them, keyed by their
// composite IDs ('log.*'); contextless user fields collapse to bare name.
// body/timestamp are NOT pinned to fixed positions — they appear where the
// caller placed them in `fields`. body/timestamp use composite IDs
// ('log.body', 'log.timestamp') since their fieldContext is fixed; user
// fields here have empty `type` so their composite collapses to bare name.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'service.name',
@@ -57,43 +59,6 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
]);
});
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log'), field('body', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// Attribute variant is its own column, not a duplicate 'log.body'.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.body',
'attribute.body',
]);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
});
it('applies the same distinct-column treatment to timestamp variants', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.timestamp',
'attribute.timestamp',
]);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
});
it('skips the synthetic "id" field name', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
@@ -112,11 +77,7 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [
field('body', 'log'),
field('timestamp', 'log'),
field('user_field'),
],
fields: [field('body'), field('timestamp'), field('user_field')],
fontSize: FontSize.SMALL,
}),
);

View File

@@ -96,18 +96,15 @@ export function useLogsTableColumns({
),
});
// Match body/timestamp by composite key, not bare name — else a variant
// like `attribute.body` collapses onto `log.body`, duplicating the column.
const fieldCols = fields
.map((f): TableColumnDef<ILog> | null => {
if (f.name === 'id') {
return null;
}
const compositeKey = buildCompositeKey(f.name, f.type);
if (compositeKey === timestampCol.id) {
if (f.name === 'timestamp') {
return timestampCol;
}
if (compositeKey === bodyCol.id) {
if (f.name === 'body') {
return bodyCol;
}
return makeUserFieldCol(f);

View File

@@ -109,16 +109,6 @@ $custom-border-color: #2c3044;
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
}
.ant-select-clear {
background-color: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover {
color: var(--l1-foreground);
}
}
// Customize tags in multiselect (dark mode by default)
.ant-select-selection-item {
background-color: var(--l1-border);
@@ -402,9 +392,7 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
// so only the list scrolls (avoids a second scrollbar on this container).
max-height: 410px;
max-height: 350px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -495,11 +483,8 @@ $custom-border-color: #2c3044;
.option-checkbox {
width: 100%;
// @signozhq/ui Checkbox renders children inside a <label> that is
// content-sized by default. Make it fill the row (min-width: 0 lets it
// shrink) so the option text below can truncate instead of overflowing.
> label {
flex: 1 1 auto;
flex: 1;
min-width: 0;
}
@@ -517,12 +502,7 @@ $custom-border-color: #2c3044;
width: 100%;
.option-label-text {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option-badge {
@@ -535,30 +515,26 @@ $custom-border-color: #2c3044;
}
}
// Size the buttons to the row's resting content height (20px) and fully
// override antd's default 32px Button box, so revealing them on hover
// never changes the row height.
.only-btn,
.only-btn {
display: none;
}
.toggle-btn {
display: none;
align-items: center;
justify-content: center;
height: 18px;
min-height: 0;
padding: 0 6px;
font-size: 12px;
line-height: 1;
border: none;
box-shadow: none;
}
&:hover {
background-color: unset;
}
.only-btn:hover {
background-color: unset;
}
.toggle-btn:hover {
background-color: unset;
}
.option-content:hover {
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.toggle-btn {
display: none;
@@ -573,6 +549,9 @@ $custom-border-color: #2c3044;
.option-checkbox:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.option-badge {
display: none;

View File

@@ -721,53 +721,6 @@ export const removeKeysFromExpression = (
return result?.text ?? '';
};
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const createVariablePlaceholderRegExp = (
variableName: string,
): RegExp => {
const escapedName = escapeRegExp(variableName);
// (?![\w.]) prevents $env from matching inside $environment or $env.attr
return new RegExp(
`(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`,
'g',
);
};
const matchesVariablePlaceholder = (
text: string,
variableName: string,
): boolean => createVariablePlaceholderRegExp(variableName).test(text);
export const removeVariableFromExpression = (
expression: string | undefined,
variableName: string,
): string => {
if (!expression) {
return '';
}
const queryPairs = extractQueryPairs(expression);
const keysToRemove = queryPairs
.filter((pair) => {
const singleValue = pair.value?.toString() ?? '';
const listValues = (pair.valueList ?? []).join(' ');
return (
matchesVariablePlaceholder(singleValue, variableName) ||
matchesVariablePlaceholder(listValues, variableName)
);
})
.map((pair) => pair.key);
if (keysToRemove.length === 0) {
return expression;
}
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value

View File

@@ -1,17 +1,17 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tooltip } from 'antd';
import refreshPaymentStatus from 'api/v3/licenses/put';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { RefreshCcw } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
function RefreshPaymentStatus({
btnShape,
type,
className,
}: {
btnShape?: 'default' | 'round' | 'circle';
type?: 'button' | 'text' | 'tooltip';
className?: string;
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicenseRefetch } = useAppContext();
@@ -31,33 +31,26 @@ function RefreshPaymentStatus({
setIsLoading(false);
};
const button = (
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
);
return (
<span className="refresh-payment-status-btn-wrapper">
{type === 'tooltip' ? (
<TooltipSimple title={t('refreshPaymentStatus')}>{button}</TooltipSimple>
) : (
button
)}
<Tooltip title={type === 'tooltip' ? t('refreshPaymentStatus') : ''}>
<Button
type={type === 'text' ? 'text' : 'default'}
shape={btnShape}
className={cx('periscope-btn', { text: type === 'text' })}
onClick={handleRefreshPaymentStatus}
icon={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</Tooltip>
</span>
);
}
RefreshPaymentStatus.defaultProps = {
btnShape: 'default',
type: 'button',
className: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -139,6 +139,7 @@ jest.mock('react-query', (): unknown => {
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));

View File

@@ -33,8 +33,7 @@ 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_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -67,8 +67,8 @@
gap: 4px;
&--success {
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
color: var(--text-forest-400);
background: color-mix(in srgb, var(--success-background) 10%, transparent);
color: var(--success-foreground);
}
&--error {

View File

@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import logEvent from 'api/common/logEvent';
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
import { USER_PREFERENCES } from 'constants/userPreferences';
import {
@@ -24,6 +24,8 @@ jest.mock('providers/cmdKProvider', () => ({
}),
}));
jest.mock('api/common/logEvent', () => jest.fn());
// Mock the AppContext
const mockUpdateUserPreferenceInContext = jest.fn();
@@ -137,7 +139,7 @@ describe('Sidebar Toggle Shortcut', () => {
it('should log the toggle event with correct parameters', async () => {
const user = userEvent.setup();
const mockHandleShortcut = jest.fn(() => {
logEventMock('Global Shortcut: Sidebar Toggle', {
logEvent('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
@@ -153,13 +155,10 @@ describe('Sidebar Toggle Shortcut', () => {
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
expect(logEventMock).toHaveBeenCalledWith(
'Global Shortcut: Sidebar Toggle',
{
previousState: false,
newState: true,
},
);
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
previousState: false,
newState: true,
});
});
it('should update user preference in context', async () => {

View File

@@ -1,199 +0,0 @@
.billingContainer {
margin-bottom: var(--spacing-20);
padding-top: 36px;
width: 90%;
margin: 0 auto;
.pageHeader {
margin-bottom: var(--spacing-8);
.pageHeaderTitle {
font-weight: var(--label-medium-500-font-weight);
font-size: var(--label-medium-500-font-size);
line-height: 32px;
letter-spacing: -0.08px;
color: var(--l1-foreground);
}
.pageHeaderSubtitle {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
color: var(--l2-foreground);
}
}
.pageInfoTitle {
margin: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
color: var(--l1-foreground);
}
.pageInfoSubtitle {
margin: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l2-foreground);
}
.pageInfo {
:global(.ant-card) {
padding: var(--padding-3);
}
.billingManageBtn {
background: var(--l3-background);
&:hover {
background: var(--l3-background-hover);
}
}
}
.billingSummary {
margin: var(--spacing-12) var(--spacing-4);
}
.billingDetails {
margin: var(--spacing-12) 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
overflow: hidden;
:global {
.ant-table {
background: var(--l2-background);
}
.ant-table-thead > tr > th {
height: 52px;
padding: 0 var(--padding-4);
color: var(--l3-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
letter-spacing: 0.48px;
text-transform: uppercase;
}
.ant-table-tbody > tr > td {
height: 52px;
padding: 0 var(--padding-4);
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
color: var(--l2-foreground);
font-size: var(--font-size-sm);
&:first-child {
color: var(--l1-foreground);
}
&:not(:first-child) {
font-feature-settings:
'zero' 1,
'lnum' 1,
'tnum' 1;
}
}
.ant-table-tbody > tr:last-child > td {
border-bottom: none;
}
.ant-table-tbody > tr:hover > td {
background: var(--l2-background) !important;
}
}
.billingDetailsHeaderCell {
position: relative;
background: var(--l2-background) !important;
border: none !important;
border-bottom: 1px solid var(--l1-border) !important;
box-shadow: none !important;
&::after {
content: '';
position: absolute;
inset-block: 0;
inset-inline-end: 0;
width: 2px;
background: var(--l2-background);
z-index: 1;
}
}
}
.upgradePlanBenefits {
margin: 0 var(--spacing-4);
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 var(--padding-12);
.planBenefits {
.planBenefit {
display: flex;
align-items: center;
gap: var(--spacing-8);
margin: var(--spacing-8) 0;
}
}
}
.billingGraphSection {
border: 1px solid var(--l1-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-4);
.billingGraphFooter {
display: flex;
gap: var(--spacing-4);
padding: var(--padding-3) var(--padding-4);
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
.billingFooterBtn {
background: var(--l3-background);
&:hover {
background: var(--l3-background-hover);
}
}
}
}
.emptyGraphCard {
:global(.ant-card-body) {
height: 40vh;
display: flex;
justify-content: center;
align-items: center;
}
}
.billingUpdateNote {
margin-top: var(--spacing-8);
font-family: var(--font-family-inter);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 22px;
letter-spacing: -0.07px;
}
:global {
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
width: 100%;
min-width: 100%;
}
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
min-width: 100% !important;
}
}
}

View File

@@ -0,0 +1,70 @@
.billing-container {
margin-bottom: 40px;
padding-top: 36px;
width: 90%;
margin: 0 auto;
.billing-summary {
margin: 24px 8px;
}
.billing-details {
margin: 24px 0px;
.ant-table-title {
color: var(--l2-foreground);
background-color: var(--l3-background);
}
.ant-table-cell {
background-color: var(--l1-background);
border-color: var(--l1-border);
}
.ant-table-tbody {
td {
border-color: var(--l1-border);
}
}
}
.upgrade-plan-benefits {
margin: 0px 8px;
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 48px;
.plan-benefits {
.plan-benefit {
display: flex;
align-items: center;
gap: 16px;
margin: 16px 0;
}
}
}
.empty-graph-card {
.ant-card-body {
height: 40vh;
display: flex;
justify-content: center;
align-items: center;
}
}
.billing-update-note {
text-align: left;
font-size: 13px;
color: var(--l2-foreground);
margin-top: 16px;
}
}
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
width: 100%;
min-width: 100%;
}
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
min-width: 100% !important;
}

View File

@@ -38,7 +38,7 @@ describe('BillingContainer', () => {
});
expect(pricePerUnit).toBeInTheDocument();
const cost = await screen.findByRole('columnheader', {
name: /cost/i,
name: /cost \(billing period to date\)/i,
});
expect(cost).toBeInTheDocument();

View File

@@ -1,11 +1,12 @@
import { Callout } from '@signozhq/ui/callout';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import React, { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import { CircleCheck, CloudDownload } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import {
Alert,
Button,
Card,
Col,
Flex,
@@ -15,10 +16,7 @@ import {
TableColumnsType as ColumnsType,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import getUsage, {
BreakdownEntry,
UsageResponsePayloadProps,
} from 'api/billing/getUsage';
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
@@ -31,7 +29,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty, pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
@@ -40,7 +38,7 @@ import CancelSubscriptionBanner from './CancelSubscriptionBanner';
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
import { prepareCsvData } from './BillingUsageGraph/utils';
import styles from './BillingContainer.module.scss';
import './BillingContainer.styles.scss';
import { LicenseState } from 'types/api/licensesV3/getActive';
interface DataType {
@@ -117,7 +115,7 @@ const dummyColumns: ColumnsType<DataType> = [
render: renderSkeletonInput,
},
{
title: 'Cost',
title: 'Cost (Billing period to date)',
dataIndex: 'cost',
key: 'cost',
render: renderSkeletonInput,
@@ -132,7 +130,7 @@ export default function BillingContainer(): JSX.Element {
const [billAmount, setBillAmount] = useState(0);
const [daysRemaining, setDaysRemaining] = useState(0);
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<DataType[]>([]);
const [data, setData] = useState<any[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<UsageResponsePayloadProps>
>({});
@@ -152,7 +150,7 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
(data: any): void => {
if (isEmpty(data?.payload)) {
return;
}
@@ -160,23 +158,27 @@ export default function BillingContainer(): JSX.Element {
details: { breakdown = [], billTotal },
billingPeriodStart,
billingPeriodEnd,
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
const formattedUsageData: DataType[] = [];
} = data?.payload || {};
const formattedUsageData: any[] = [];
if (breakdown && Array.isArray(breakdown)) {
for (let index = 0; index < breakdown.length; index += 1) {
const element: BreakdownEntry = breakdown[index];
const element = breakdown[index];
element?.tiers?.forEach((tier, i: number) => {
formattedUsageData.push({
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
unit: element?.unit ?? '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: String(tier.unitPrice),
cost: `$ ${tier.tierCost}`,
});
});
element?.tiers.forEach(
(
tier: { quantity: number; unitPrice: number; tierCost: number },
i: number,
) => {
formattedUsageData.push({
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: tier.unitPrice,
cost: `$ ${tier.tierCost}`,
});
},
);
}
}
@@ -249,19 +251,16 @@ export default function BillingContainer(): JSX.Element {
title: 'Data Ingested',
dataIndex: 'dataIngested',
key: 'dataIngested',
align: 'right',
},
{
title: 'Price per Unit',
dataIndex: 'pricePerUnit',
key: 'pricePerUnit',
align: 'right',
},
{
title: 'Cost',
title: 'Cost (Billing period to date)',
dataIndex: 'cost',
key: 'cost',
align: 'right',
},
];
@@ -346,6 +345,23 @@ export default function BillingContainer(): JSX.Element {
updateCreditCard,
]);
const BillingUsageGraphCallback = useCallback(
() =>
!isLoading && !isFetchingBillingData ? (
<>
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
<div className="billing-update-note">
Note: Billing metrics are updated once every 24 hours.
</div>
</>
) : (
<Card className="empty-graph-card" bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
),
[apiResponse, billAmount, isLoading, isFetchingBillingData],
);
const subscriptionPastDueMessage = (): JSX.Element => (
<Typography>
{`We were not able to process payments for your account. Please update your card details `}
@@ -399,12 +415,12 @@ export default function BillingContainer(): JSX.Element {
trialInfo?.gracePeriodEnd;
return (
<div className={styles.billingContainer}>
<Flex vertical gap={4} className={styles.pageHeader}>
<Typography.Text className={styles.pageHeaderTitle}>
<div className="billing-container">
<Flex vertical style={{ marginBottom: 16 }}>
<Typography.Text style={{ fontWeight: 500, fontSize: 18 }}>
{t('billing')}
</Typography.Text>
<Typography.Text className={styles.pageHeaderSubtitle}>
<Typography.Text color="muted">
{t('manage_billing_and_costs')}
</Typography.Text>
</Flex>
@@ -412,36 +428,50 @@ export default function BillingContainer(): JSX.Element {
<Card
bordered={false}
style={{ minHeight: 150, marginBottom: 16 }}
className={styles.pageInfo}
className="page-info"
>
<Flex justify="space-between" align="center">
<Flex vertical gap={8}>
<p className={styles.pageInfoTitle}>
<Flex vertical>
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
{isCloudUserVal ? t('teams_cloud') : t('teams')}{' '}
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</p>
</Typography.Title>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
<p className={styles.pageInfoSubtitle}>
<Typography.Text style={{ fontSize: 12, color: Color.BG_VANILLA_400 }}>
{daysRemaining} {daysRemainingStr}
</p>
</Typography.Text>
) : null}
</Flex>
<Button
testId="header-billing-button"
variant="solid"
color="secondary"
size="md"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading}
onClick={handleBilling}
prefix={<Landmark size={14} />}
className={styles.billingManageBtn}
>
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
<Flex gap={8}>
<Button
type="default"
size="middle"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading || isFetchingBillingData}
onClick={handleCsvDownload}
className="periscope-btn"
>
<Flex align="center" justify="center" gap={4}>
<CloudDownload size="md" />
Download CSV
</Flex>
</Button>
<Button
data-testid="header-billing-button"
type="primary"
size="middle"
loading={isLoadingBilling || isLoadingManageBilling}
disabled={isLoading}
onClick={handleBilling}
>
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
<RefreshPaymentStatus type="tooltip" />
</Flex>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -455,8 +485,8 @@ export default function BillingContainer(): JSX.Element {
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage
? headerText && (
<Callout
title={headerText}
<Alert
message={headerText}
type="info"
showIcon
style={{ marginTop: 12 }}
@@ -473,8 +503,8 @@ export default function BillingContainer(): JSX.Element {
billingData &&
trialInfo?.gracePeriodEnd &&
showGracePeriodMessage ? (
<Callout
title={`Your data is safe with us until ${getFormattedDate(
<Alert
message={`Your data is safe with us until ${getFormattedDate(
trialInfo?.gracePeriodEnd || Date.now(),
)}. Please upgrade plan now to retain your data.`}
type="info"
@@ -485,69 +515,26 @@ export default function BillingContainer(): JSX.Element {
{isSubscriptionPastDue &&
(!isLoading && !isFetchingBillingData ? (
<Callout type="error" showIcon style={{ marginTop: 12 }}>
{subscriptionPastDueMessage()}
</Callout>
<Alert
message={subscriptionPastDueMessage()}
type="error"
showIcon
style={{ marginTop: 12 }}
/>
) : (
<Skeleton.Input active style={{ height: 20, marginTop: 20 }} />
))}
</Card>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
</div>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
<BillingUsageGraphCallback />
<div className={styles.billingDetails}>
<div className="billing-details">
{!isLoading && !isFetchingBillingData && (
<Table
columns={columns}
dataSource={data}
pagination={false}
bordered={false}
components={{
header: {
cell: ({
style,
...props
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
return (
<th
{...props}
style={safeStyle}
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
/>
);
},
},
}}
/>
)}
@@ -559,7 +546,7 @@ export default function BillingContainer(): JSX.Element {
)}
{!trialInfo?.trialConvertedToSubscription && (
<div className={styles.upgradePlanBenefits}>
<div className="upgrade-plan-benefits">
<Row
justify="space-between"
align="middle"
@@ -568,16 +555,16 @@ export default function BillingContainer(): JSX.Element {
}}
gutter={[16, 16]}
>
<Col span={20} className={styles.planBenefits}>
<Typography.Text className={styles.planBenefit}>
<Col span={20} className="plan-benefits">
<Typography.Text className="plan-benefit">
<CircleCheck size="md" />
{t('upgrade_now_text')}
</Typography.Text>
<Typography.Text className={styles.planBenefit}>
<Typography.Text className="plan-benefit">
<CircleCheck size="md" />
{t('Your billing will start only after the trial period')}
</Typography.Text>
<Typography.Text className={styles.planBenefit}>
<Typography.Text className="plan-benefit">
<CircleCheck size="md" />
<span>
{t('checkout_plans')} &nbsp;
@@ -596,10 +583,9 @@ export default function BillingContainer(): JSX.Element {
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
testId="upgrade-plan-button"
variant="solid"
color="primary"
size="md"
data-testid="upgrade-plan-button"
type="primary"
size="middle"
loading={isLoadingBilling || isLoadingManageBilling}
onClick={handleBilling}
>

View File

@@ -1,9 +0,0 @@
.headerRow {
padding: var(--spacing-8);
}
.itemList {
overflow-y: auto;
max-height: 300px;
padding: var(--padding-3);
}

View File

@@ -1,95 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
TooltipContentItem,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
import Styles from './BillingBarChartTooltip.module.scss';
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
billingApiResponse: MetricRangePayloadProps;
}
const CURRENCY_SYMBOL = '$';
export function BillingBarChartTooltip({
billingApiResponse,
uPlotInstance,
dataIndexes,
seriesIndex,
isPinned,
}: BillingBarChartTooltipProps): JSX.Element {
const content = useMemo((): TooltipContentItem[] => {
const baseItems = buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIndex: seriesIndex,
uPlotInstance,
yAxisUnit: '',
isStackedBarChart: true,
});
return baseItems.map((item) => {
const match = billingApiResponse.data.result.find(
(r) => (r.legend || r.queryName) === item.label,
);
if (!match) {
return item;
}
const seriesIdx = uPlotInstance.series.findIndex(
(s) => s.label === item.label,
);
if (seriesIdx === -1) {
return item;
}
const dataIndex = dataIndexes[seriesIdx];
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
const unit = match.unit ?? '';
const quantityStr =
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
return {
...item,
tooltipValue: `${CURRENCY_SYMBOL}${getToolTipValue(item.value, '')}${quantityStr}`,
};
});
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
const activeItem = content.find((item) => item.isActive) ?? null;
return (
<div
className={cx(TooltipStyles.container, {
[TooltipStyles.pinned]: isPinned,
})}
data-testid="uplot-tooltip-container"
>
<TooltipHeader
uPlotInstance={uPlotInstance}
showTooltipHeader
isPinned={isPinned}
activeItem={null}
headerRowClassName={Styles.headerRow}
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
/>
{activeItem != null && <span className={TooltipStyles.divider} />}
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
{content.map((item) => (
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
))}
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
.graphContainer {
height: 100%;
}
.billingGraphCard {
:global {
.uplot-no-data {
display: flex;
align-items: center;
justify-content: center;
}
.ant-card-body {
height: 40vh;
.uplot-graph-container {
padding: 8px;
}
}
}
.totalSpent {
font-family: 'SF Mono', monospace;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
}
.totalSpentTitle {
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
color: var(--l2-foreground);
}
}

View File

@@ -0,0 +1,23 @@
.billing-graph-card {
.ant-card-body {
height: 40vh;
.uplot-graph-container {
padding: 8px;
}
}
.total-spent {
font-family: 'SF Mono' monospace;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
}
.total-spent-title {
font-size: 12px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.48px;
color: var(--l2-foreground);
}
}

View File

@@ -1,146 +1,221 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Card, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type uPlot from 'uplot';
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin';
import getAxes from 'lib/uPlotLib/utils/getAxes';
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale';
import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale';
import uPlot from 'uplot';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
import {
calculateStartEndTime,
convertDataToMetricRangePayload,
fillMissingValuesForQuantities,
} from './utils';
import styles from './BillingUsageGraph.module.scss';
import './BillingUsageGraph.styles.scss';
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
interface BillingUsageGraphProps {
data: Partial<UsageResponsePayloadProps>;
data: any;
billAmount: number;
}
const paths = (
u: any,
seriesIdx: number,
idx0: number,
idx1: number,
extendGap: boolean,
buildClip: boolean,
): uPlot.Series.PathBuilder => {
const s = u.series[seriesIdx];
const style = s.drawStyle;
const interp = s.lineInterpolation;
const numberFormatter = new Intl.NumberFormat('en-US');
const renderer = getRenderer(style, interp);
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
};
const calculateStartEndTime = (
data: any,
): { startTime: number; endTime: number } => {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown: any) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
timestamps.push(entry?.timestamp);
});
});
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
const startTime: number = Math.min(...timestamps, ...billingTime);
const endTime: number = Math.max(...timestamps, ...billingTime);
return { startTime, endTime };
};
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { data, billAmount } = props;
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
data?.details?.breakdown?.forEach((breakdown: any) => {
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
total: 0,
};
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
}
});
const graphCompatibleData = useMemo(
() => convertDataToMetricRangePayload(data),
[data],
);
const chartData = getUPlotChartData(graphCompatibleData);
const graphRef = useRef<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
// Single-day data causes bars to span multiple days — add a synthetic
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
const normalizedData = useMemo(() => {
if (!data?.details?.breakdown) {
return data;
}
return {
...data,
details: {
...data.details,
breakdown: data.details.breakdown.map((breakdown) => {
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
return breakdown;
}
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
total: 0,
};
return {
...breakdown,
dayWiseBreakdown: {
...breakdown.dayWiseBreakdown,
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
},
};
}),
},
};
}, [data]);
const graphCompatibleData = useMemo(
() => convertDataToMetricRangePayload(normalizedData),
[normalizedData],
);
const chartData = useMemo(
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
[graphCompatibleData],
);
const filledApiResponse = useMemo(
(): MetricRangePayloadProps =>
fillMissingValuesForQuantities(
graphCompatibleData,
chartData[0] as number[],
),
[graphCompatibleData, chartData],
);
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
[normalizedData],
() => calculateStartEndTime(data),
[data],
);
const config = useMemo(
() =>
prepareBillingBarConfig({
isDarkMode,
// Subtract 86400s (one day) from startTime to add a buffer before first bar
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
maxTimeScale: endTime,
apiResponse: graphCompatibleData,
}),
[isDarkMode, startTime, endTime, graphCompatibleData],
const getGraphSeries = (color: string, label: string): any => ({
drawStyle: 'bars',
paths,
lineInterpolation: 'spline',
show: true,
label,
fill: color,
stroke: color,
width: 2,
spanGaps: true,
points: {
size: 5,
show: false,
stroke: color,
},
});
const uPlotSeries: any = useMemo(
() => [
{ label: 'Timestamp', stroke: 'purple' },
getGraphSeries(
'#7CEDBE',
graphCompatibleData.data.result[0]?.legend as string,
),
getGraphSeries(
'#4E74F8',
graphCompatibleData.data.result[1]?.legend as string,
),
getGraphSeries(
'#F24769',
graphCompatibleData.data.result[2]?.legend as string,
),
],
[graphCompatibleData.data.result],
);
const renderBillingTooltip = useCallback(
(args: TooltipRenderArgs) => (
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
),
[filledApiResponse],
const axesOptions = getAxes({ isDarkMode, yAxisUnit: '' });
const optionsForChart: uPlot.Options = useMemo(
() => ({
id: 'billing-usage-breakdown',
series: uPlotSeries,
width: containerDimensions.width,
height: containerDimensions.height - 30,
axes: [
{
...axesOptions[0],
grid: {
...axesOptions.grid,
show: false,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
},
{
...axesOptions[1],
stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400,
},
],
scales: {
x: {
...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start
},
y: {
...getYAxisScale({
series: graphCompatibleData?.data?.newResult?.data?.result,
yAxisUnit: '',
softMax: null,
softMin: null,
}),
},
},
legend: {
show: true,
live: false,
isolate: true,
},
cursor: {
lock: false,
focus: {
prox: 1e6,
bias: 1,
},
},
focus: {
alpha: 0.3,
},
padding: [32, 32, 16, 16],
plugins: [
tooltipPlugin({
apiResponse: fillMissingValuesForQuantities(
graphCompatibleData,
chartData[0],
),
yAxisUnit: '',
isBillingUsageGraphs: true,
isDarkMode,
}),
],
}),
[
axesOptions,
chartData,
containerDimensions.height,
containerDimensions.width,
endTime,
graphCompatibleData,
isDarkMode,
startTime,
uPlotSeries,
],
);
const numberFormatter = new Intl.NumberFormat('en-US');
return (
<Card bordered={false} className={styles.billingGraphCard}>
<Card bordered={false} className="billing-graph-card">
<Flex justify="space-between">
<Flex vertical gap={6}>
<Typography.Text className={styles.totalSpentTitle}>
<Typography.Text className="total-spent-title">
TOTAL SPENT
</Typography.Text>
<Typography.Text className={styles.totalSpent}>
<Typography.Text className="total-spent">
${numberFormatter.format(billAmount)}
</Typography.Text>
</Flex>
</Flex>
<div ref={graphRef} className={styles.graphContainer}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
config={config}
data={chartData}
isStackedBarChart
legendConfig={{ position: LegendPosition.BOTTOM }}
customTooltip={renderBillingTooltip}
width={containerDimensions.width}
height={containerDimensions.height - 30}
canPinTooltip
/>
)}
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
<Uplot data={chartData} options={optionsForChart} />
</div>
</Card>
);

View File

@@ -1,165 +0,0 @@
import React from 'react';
import { render, screen } from 'tests/test-utils';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
buildTooltipContent: jest.fn().mockReturnValue([
{
label: 'Logs',
value: 100,
tooltipValue: '$100.00',
color: '#7CEDBE',
isActive: true,
isHighlighted: false,
},
{
label: 'Traces',
value: 50,
tooltipValue: '$50.00',
color: '#4E74F8',
isActive: false,
isHighlighted: false,
},
]),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn().mockReturnValue(false),
}));
function makeUPlotInstance(seriesLabels: string[]): uPlot {
return {
data: [
[1000, 2000],
[100, 200],
[50, 80],
],
cursor: { idx: 0 },
series: [
{ label: 'Timestamp', show: true, stroke: '#000' },
...seriesLabels.map((label) => ({
label,
show: true,
stroke: '#aabbcc',
})),
],
} as unknown as uPlot;
}
function makeBillingApiResponse(
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
): MetricRangePayloadProps {
return {
data: {
result: entries.map((e) => ({
legend: e.legend,
queryName: e.legend,
metric: {},
values: [[1000, '10']] as [number, string][],
quantity: e.quantity as number[],
unit: e.unit,
})),
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
};
}
const baseTooltipArgs = {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
seriesIndex: 1,
dataIndexes: [null, 0, 0],
};
describe('BillingBarChartTooltip', () => {
it('augments tooltipValue with quantity and unit for each series', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
});
it('omits quantity line when quantity at dataIndex is null', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
});
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
const uPlotInstance = makeUPlotInstance(['Logs']);
const { buildTooltipContent } = jest.requireMock(
'lib/uPlotV2/components/Tooltip/utils',
) as { buildTooltipContent: jest.Mock };
buildTooltipContent.mockReturnValueOnce([
{
label: 'Logs',
value: 0.3076171875,
tooltipValue: '$0.31',
color: '#7CEDBE',
isActive: true,
isHighlighted: false,
},
]);
const billingApiResponse = makeBillingApiResponse([
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
});
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
const billingApiResponse = makeBillingApiResponse([]);
render(
<BillingBarChartTooltip
{...baseTooltipArgs}
uPlotInstance={uPlotInstance}
billingApiResponse={billingApiResponse}
/>,
);
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
});
});

View File

@@ -1,101 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
data: {
result: legends.map((legend) => ({
legend,
queryName: legend,
metric: {},
values: [[1000, '10']],
})),
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
});
describe('prepareBillingBarConfig', () => {
const baseProps = { isDarkMode: false };
it('returns a builder with no series when apiResponse is undefined', () => {
const builder = prepareBillingBarConfig(baseProps);
const config = builder.getConfig();
expect(config.series).toHaveLength(1);
});
it('returns a builder with no series when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.series).toHaveLength(1);
});
it('adds one series per result entry with correct labels and colors', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.series).toHaveLength(4);
expect(config.series?.[1]?.label).toBe('Logs');
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
expect(config.series?.[2]?.label).toBe('Traces');
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
expect(config.series?.[3]?.label).toBe('Metrics');
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
});
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
});
const config = builder.getConfig();
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
});
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
});
const config = builder.getConfig();
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
expect(config.focus).toStrictEqual({ alpha: 0.3 });
});
it('sets no bands when result is empty', () => {
const builder = prepareBillingBarConfig({
...baseProps,
apiResponse: makeApiResponse([]),
});
const config = builder.getConfig();
expect(config.bands).toBeUndefined();
});
it('uses queryName as label when legend is undefined', () => {
const apiResponse: MetricRangePayloadProps = {
data: {
result: [
{
legend: undefined as any,
queryName: 'Logs',
metric: {},
values: [[1000, '10']],
},
],
resultType: '',
newResult: { data: { result: [], resultType: '' } },
},
};
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
const config = builder.getConfig();
expect(config.series?.[1]?.label).toBe('Logs');
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
});
});

View File

@@ -1,145 +0,0 @@
import {
calculateStartEndTime,
convertDataToMetricRangePayload,
} from '../utils';
const makeData = (
timestamps: number[],
billingPeriodStart?: number,
billingPeriodEnd?: number,
) => ({
billingPeriodStart,
billingPeriodEnd,
details: {
total: 0,
baseFee: 0,
billTotal: 0,
breakdown: [
{
type: 'Logs',
unit: 'GB',
dayWiseBreakdown: {
breakdown: timestamps.map((timestamp) => ({
timestamp,
total: 0,
quantity: 0,
count: 0,
size: 0,
})),
},
},
],
},
});
describe('convertDataToMetricRangePayload', () => {
it('returns empty result when all dayWiseBreakdown.breakdown are null', () => {
const data = {
billingPeriodStart: 1778763678,
billingPeriodEnd: 1781442078,
details: {
total: 0,
baseFee: 49,
billTotal: 49,
breakdown: [
{
type: 'Metrics',
unit: 'Million',
tiers: [],
dayWiseBreakdown: { type: '', breakdown: null },
},
{
type: 'Traces',
unit: 'GB',
tiers: [],
dayWiseBreakdown: { type: '', breakdown: null },
},
{
type: 'Logs',
unit: 'GB',
tiers: [],
dayWiseBreakdown: { type: '', breakdown: null },
},
],
},
};
const result = convertDataToMetricRangePayload(data);
expect(result.data.result).toHaveLength(0);
});
it('includes only series that have day-wise data', () => {
const data = {
details: {
breakdown: [
{
type: 'Metrics',
unit: 'Million',
dayWiseBreakdown: { breakdown: null },
},
{
type: 'Logs',
unit: 'GB',
dayWiseBreakdown: {
breakdown: [
{ timestamp: 1000, total: 5, quantity: 10, count: 0, size: 0 },
],
},
},
],
},
};
const result = convertDataToMetricRangePayload(data);
expect(result.data.result).toHaveLength(1);
expect(result.data.result[0].legend).toBe('Logs');
});
});
describe('calculateStartEndTime', () => {
it('returns min/max of all breakdown timestamps', () => {
const data = makeData([1000, 3000, 2000]);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 1000,
endTime: 3000,
});
});
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
const data = makeData([2000, 3000], 500, 4000);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 500,
endTime: 4000,
});
});
it('returns undefined when there are no timestamps and no billing period', () => {
expect(calculateStartEndTime({})).toStrictEqual({
startTime: undefined,
endTime: undefined,
});
});
it('returns undefined when breakdown is empty', () => {
const data = makeData([]);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: undefined,
endTime: undefined,
});
});
it('filters out non-finite billingPeriod values', () => {
const data = makeData([1000], NaN, Infinity);
expect(calculateStartEndTime(data)).toStrictEqual({
startTime: 1000,
endTime: 1000,
});
});
it('works when details is missing', () => {
expect(
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
).toStrictEqual({
startTime: 100,
endTime: 200,
});
});
});

View File

@@ -1,71 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
const BILLING_SERIES_COLORS = [
Color.BG_FOREST_300,
Color.BG_ROBIN_500,
Color.BG_SAKURA_500,
];
export interface PrepareBillingBarConfigProps {
isDarkMode: boolean;
timezone?: Timezone;
minTimeScale?: number;
maxTimeScale?: number;
apiResponse?: MetricRangePayloadProps;
}
export function prepareBillingBarConfig({
isDarkMode,
timezone,
minTimeScale,
maxTimeScale,
apiResponse,
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
const builder = buildBaseConfig({
id: 'billing-usage-breakdown',
isDarkMode,
timezone,
panelType: PANEL_TYPES.BAR,
minTimeScale,
maxTimeScale,
});
const results = apiResponse?.data?.result;
if (!results?.length) {
return builder;
}
const labels = results.map((s) => s.legend || s.queryName || '');
const colorMapping = labels.reduce<Record<string, string>>(
(acc, label, index) => {
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
return acc;
},
{},
);
labels.forEach((label) => {
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
metric: {},
});
});
builder.setBands(getInitialStackedBands(results.length));
builder.setPadding([32, 32, 16, 16]);
builder.setFocus({ alpha: 0.3 });
return builder;
}

View File

@@ -1,7 +1,7 @@
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { isEmpty, isNull } from 'lodash-es';
import { unparse } from 'papaparse';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -29,25 +29,23 @@ export const convertDataToMetricRangePayload = (
return emptyStateData;
}
const payload = breakdown
.map((info: any) => {
const metric = info.type;
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
(a: any, b: any) => a.timestamp - b.timestamp,
);
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
categoryInfo.timestamp,
categoryInfo.total,
]);
const queryName = info.type;
const legend = info.type;
const { unit } = info;
const quantity = sortedBreakdownData.map(
(categoryInfo: any) => categoryInfo.quantity,
);
return { metric, values, queryName, legend, quantity, unit };
})
.filter((series: any) => series.values.length > 0);
const payload = breakdown.map((info: any) => {
const metric = info.type;
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
(a: any, b: any) => a.timestamp - b.timestamp,
);
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
categoryInfo.timestamp,
categoryInfo.total,
]);
const queryName = info.type;
const legend = info.type;
const { unit } = info;
const quantity = sortedBreakdownData.map(
(categoryInfo: any) => categoryInfo.quantity,
);
return { metric, values, queryName, legend, quantity, unit };
});
const sortedData = payload.sort((a: any, b: any) => {
const sumA = a.values.reduce((acc: any, val: any) => acc + val[1], 0);
@@ -122,40 +120,11 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
fileName: string;
} {
const graphCompatibleData = convertDataToMetricRangePayload(data);
const chartData = prepareChartData(graphCompatibleData);
const quantityMapArr = quantityDataArr(
graphCompatibleData,
chartData[0] as number[],
);
const chartData = getUPlotChartData(graphCompatibleData);
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
return {
csvData: unparse(generateCsvData(quantityMapArr)),
fileName: csvFileName(quantityMapArr),
};
}
export function calculateStartEndTime(
data: Partial<UsageResponsePayloadProps>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
timestamps.push(entry.timestamp);
});
});
const billingTime: number[] = [
data?.billingPeriodStart,
data?.billingPeriodEnd,
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
const allTimes = [...timestamps, ...billingTime];
if (allTimes.length === 0) {
return { startTime: undefined, endTime: undefined };
}
return {
startTime: Math.min(...allTimes),
endTime: Math.max(...allTimes),
};
}

View File

@@ -153,7 +153,6 @@
font-size: 10px;
color: var(--l2-foreground);
margin-top: 4px;
display: block;
}
}

View File

@@ -47,10 +47,18 @@
}
.ant-tabs-tab-active {
.overview-btn,
.variables-btn,
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.public-dashboard-btn {
color: var(--primary-background);
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
}

View File

@@ -127,15 +127,6 @@
align-items: center;
justify-content: center;
gap: 4px;
.sidenav-beta-tag {
margin-left: 4px;
}
div {
display: flex;
align-items: center;
}
}
.variable-type-btn + .variable-type-btn {
@@ -186,7 +177,6 @@
.multiple-values-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
@@ -203,7 +193,6 @@
.all-option-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {

View File

@@ -518,6 +518,7 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
@@ -613,6 +614,7 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}

View File

@@ -1,328 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Dashboard } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
// ---------------------------------------------------------------------------
// Shared fixture helpers
// ---------------------------------------------------------------------------
const EMPTY_BUILDER = {
queryData: [] as any,
queryFormulas: [],
queryTraceOperator: [],
};
const BASE_WIDGET = {
opacity: '1',
nullZeroValues: 'null',
timePreferance: 'GLOBAL_TIME' as const,
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
};
const DEFAULT_QUERY_DATA = {
queryName: 'q1',
// In QB v5, expression holds the query label (A/B/C), not a filter expression
expression: 'A',
dataSource: DataSource.METRICS,
functions: [],
groupBy: [],
filters: { items: [] as any[], op: 'AND' as const },
legend: '',
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
selectColumns: [],
source: '' as const,
};
/**
* Build a dashboard with a single builder widget.
* Only supply the fields your test actually cares about.
*/
const buildBuilderDashboard = (
filterExpression: string,
queryDataOverrides: Record<string, any> = {},
): Dashboard => ({
id: 'dash1',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'Test Dashboard',
widgets: [
{
...BASE_WIDGET,
id: 'widget-1',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: 'Widget 1',
description: '',
query: {
id: 'query1',
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
{
...DEFAULT_QUERY_DATA,
...queryDataOverrides,
filter: { expression: filterExpression },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
unit: '',
},
},
],
variables: {},
},
});
const buildClickhouseDashboard = (query: string): Dashboard => ({
id: 'dash-ch',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'CH',
widgets: [
{
...BASE_WIDGET,
id: 'w1',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: '',
description: '',
query: {
id: 'q1',
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
builder: EMPTY_BUILDER,
unit: '',
},
},
],
variables: {},
},
});
const buildPromqlDashboard = (query: string): Dashboard => ({
id: 'dash-prom',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'PromQL Dashboard',
widgets: [
{
...BASE_WIDGET,
id: 'widget-prom',
panelTypes: PANEL_TYPES.TIME_SERIES,
title: 'PromQL Widget',
description: '',
query: {
id: 'query-prom',
queryType: EQueryType.PROM,
promql: [{ name: 'A', query, legend: '', disabled: false }],
clickhouse_sql: [],
builder: EMPTY_BUILDER,
unit: '',
},
},
],
variables: {},
},
});
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
const chQuery = (sql: string, varName: string): string => {
const result = removeVariableReferencesFromDashboard(
buildClickhouseDashboard(sql),
varName,
);
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
};
/** Extract the first builder queryData from a cleaned dashboard. */
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('removeVariableReferencesFromDashboard', () => {
describe('builder filter expression cleanup', () => {
it('removes a variable clause from filter.expression', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
});
it('leaves no dangling AND/OR after removing a variable clause', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
const { expression } = firstBuilderQueryData(result).filter;
expect(expression).toBe("env = 'prod'");
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
});
it('does not remove $environment clause when deleting $env', () => {
const dashboard = buildBuilderDashboard(
'env = $env AND deployment.environment = $environment',
);
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
expect(firstBuilderQueryData(result).filter.expression).toBe(
'deployment.environment = $environment',
);
});
it('leaves literal filter expressions untouched when removing a variable', () => {
const dashboard = buildBuilderDashboard(
"service.name = 'api-gateway' AND env = 'prod'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe(
"service.name = 'api-gateway' AND env = 'prod'",
);
});
it('removes only the variable clause, preserving a literal clause on the same key', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND service.name = 'api-gateway'",
);
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe(
"service.name = 'api-gateway'",
);
});
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
const dashboard = buildBuilderDashboard("env = 'prod'");
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
});
});
describe('PromQL query cleanup', () => {
it('removes variable placeholder from a promql query', () => {
const result = removeVariableReferencesFromDashboard(
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
'service',
);
const widget = result!.data.widgets![0] as any;
expect(widget.query.promql[0].query).toBe(
'sum(rate(http_requests_total{}[5m]))',
);
});
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
const result = removeVariableReferencesFromDashboard(
buildPromqlDashboard('up{env="$env", job="api"}'),
'env',
);
const widget = result!.data.widgets![0] as any;
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
});
});
describe('ClickHouse SQL query cleanup', () => {
it('removes a quoted variable clause and its WHERE keyword', () => {
expect(
chQuery(
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
'service',
),
).toBe('SELECT count() FROM signoz_logs');
});
it('removes a middle clause: AND env={{.env}} AND', () => {
expect(
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
});
it('removes the first clause: env={{.env}} AND rest', () => {
expect(
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
).toBe('SELECT count() FROM t WHERE b=2');
});
it('removes the last clause: rest AND env=$env', () => {
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
'SELECT count() FROM t WHERE a=1',
);
});
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
'SELECT count() FROM t',
);
});
it('falls back to token-only strip for a bare variable in SELECT', () => {
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
'SELECT FROM table',
);
});
});
describe('edge cases', () => {
it('is idempotent — calling twice produces the same result', () => {
const dashboard = buildBuilderDashboard(
"service.name IN $service AND env = 'prod'",
);
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
const twice = removeVariableReferencesFromDashboard(once, 'service');
expect(twice).toStrictEqual(once);
});
it('handles a dashboard with no widgets without throwing', () => {
const dashboard: Dashboard = {
id: 'dash-empty',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
};
expect(() =>
removeVariableReferencesFromDashboard(dashboard, 'service'),
).not.toThrow();
});
});
});

View File

@@ -1,8 +1,6 @@
import {
convertFiltersToExpressionWithExistingQuery,
createVariablePlaceholderRegExp,
removeKeysFromExpression,
removeVariableFromExpression,
} from 'components/QueryBuilderV2/utils';
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
@@ -159,139 +157,6 @@ const updateAfterRemoval = (
};
};
const removeVariablePlaceholders = (
text: string | undefined,
variableName: string,
): string => {
if (!text) {
return '';
}
const tokenPattern = createVariablePlaceholderRegExp(variableName);
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
// Handles three shapes:
// (a) preceding conjunction: AND key = $var
// (b) following conjunction: key = $var AND
// (c) standalone clause: key = $var (end of expression)
const escapedToken = tokenPattern.source;
const clausePattern = new RegExp(
// (a) conjunction before the clause
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
// (b)+(c) clause first, optional conjunction after
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
'gi',
);
const withClauseRemoval = text.replace(clausePattern, '');
if (withClauseRemoval !== text) {
return withClauseRemoval
.replace(/\s{2,}/g, ' ')
.replace(/\bWHERE\s*$/i, '')
.trim();
}
// Step 2: fallback — bare variable usage outside a key-op-value pattern
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
return text
.replace(tokenPattern, '')
.replace(/\s{2,}/g, ' ')
.trim();
};
const removeVariableReferencesFromQueryData = (
queryData: IBuilderQuery,
variableName: string,
): IBuilderQuery => {
const updatedFilter = queryData.filter?.expression
? {
...queryData.filter,
expression: removeVariableFromExpression(
queryData.filter.expression,
variableName,
),
}
: queryData.filter;
return { ...queryData, filter: updatedFilter };
};
const removeVariableReferencesFromWidget = (
widget: Widgets,
variableName: string,
): Widgets => {
let updatedWidget = { ...widget };
if (updatedWidget.query?.builder?.queryData) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
builder: {
...updatedWidget.query.builder,
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
removeVariableReferencesFromQueryData(queryData, variableName),
),
},
},
};
}
if (updatedWidget.query?.promql) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
promql: updatedWidget.query.promql.map((promqlQuery) => ({
...promqlQuery,
query: removeVariablePlaceholders(promqlQuery.query, variableName),
})),
},
};
}
if (updatedWidget.query?.clickhouse_sql) {
updatedWidget = {
...updatedWidget,
query: {
...updatedWidget.query,
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
...sqlQuery,
query: removeVariablePlaceholders(sqlQuery.query, variableName),
})),
},
};
}
return updatedWidget;
};
export const removeVariableReferencesFromDashboard = (
dashboard: Dashboard | undefined,
variableName: string,
): Dashboard | undefined => {
if (!dashboard || !variableName) {
return dashboard;
}
const updatedDashboard = cloneDeep(dashboard);
if (updatedDashboard.data.widgets) {
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
(widget) => {
if ('query' in widget) {
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
}
return widget;
},
);
}
return updatedDashboard;
};
/**
* A function that takes a dashboard configuration and a list of tag filters
* and returns an updated dashboard with the filters appended to widget queries.

View File

@@ -18,11 +18,10 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { toast } from '@signozhq/ui/sonner';
import { useNotifications } from 'hooks/useNotifications';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
import { TVariableMode } from './types';
import VariableItem from './VariableItem/VariableItem';
@@ -93,6 +92,8 @@ function VariablesSettings({
const { dashboardData, setDashboardData } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
@@ -200,7 +201,9 @@ function VariablesSettings({
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
notifications.success({
message: t('variable_updated_successfully'),
});
}
},
},
@@ -253,11 +256,6 @@ function VariablesSettings({
};
const handleDeleteConfirm = (): void => {
if (!dashboardData || !variableToDelete.current) {
setDeleteVariableModal(false);
return;
}
const newVariablesArr = variablesTableData.filter(
(variable: IDashboardVariable) =>
variable.id !== variableToDelete?.current?.id,
@@ -265,31 +263,7 @@ function VariablesSettings({
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
const cleanedDashboard =
removeVariableReferencesFromDashboard(
dashboardData,
variableToDelete.current.name || '',
) || dashboardData;
updateMutation.mutateAsync(
{
id: dashboardData.id,
data: {
...cleanedDashboard.data,
variables: updatedVariables,
},
},
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
toast.success(t('variable_updated_successfully'));
}
},
},
);
updateVariables(updatedVariables);
variableToDelete.current = null;
setDeleteVariableModal(false);
};
@@ -502,7 +476,6 @@ function VariablesSettings({
open={deleteVariableModal}
onOk={handleDeleteConfirm}
onCancel={handleDeleteCancel}
okButtonProps={{ loading: updateMutation.isLoading }}
>
<Typography.Text>
Are you sure you want to delete variable{' '}

View File

@@ -1,7 +1,8 @@
import { renderHook } from '@testing-library/react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { usePanelContextMenu } from '../usePanelContextMenu';
@@ -46,7 +47,10 @@ const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
const mockQueryResponse = {
data: undefined,
isLoading: false,
} as unknown as UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
} as unknown as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
describe('usePanelContextMenu', () => {
beforeEach(() => {

View File

@@ -10,13 +10,17 @@ import {
PopoverPosition,
useCoordinates,
} from 'periscope/components/ContextMenu';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesContextMenuParams {
widget: Widgets;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
enableDrillDown?: boolean;
}

View File

@@ -1,10 +1,16 @@
import { logEventMock } from '__tests__/logEventMock';
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
@@ -78,7 +84,7 @@ describe('TooltipFooter', () => {
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);

View File

@@ -19,7 +19,6 @@
}
.ant-btn-default {
border-color: transparent;
box-shadow: none;
}
}
.ant-tabs-tab-active {

View File

@@ -1,6 +1,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
@@ -66,6 +67,20 @@ function GridCardGraph({
const [errorMessage, setErrorMessage] = useState<string>();
const [isInternalServerError, setIsInternalServerError] =
useState<boolean>(false);
const queryRangeCalledRef = useRef(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (!queryRangeCalledRef.current) {
Sentry.captureEvent({
message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`,
level: 'warning',
});
}
}, 120000);
return (): void => clearTimeout(timeoutId);
}, [widget?.id]);
const {
minTime,
maxTime,
@@ -256,12 +271,14 @@ function GridCardGraph({
});
}
}
queryRangeCalledRef.current = true;
},
onSettled: (data) => {
dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
getGraphData?.(data?.payload?.data);
queryRangeCalledRef.current = true;
},
},
);

View File

@@ -5,11 +5,9 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricQueryRangeSuccessResponse,
MetricRangePayloadProps,
} from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import uPlot from 'uplot';
@@ -23,7 +21,10 @@ export interface GraphVisibilityLegendEntryProps {
export interface WidgetGraphComponentProps {
widget: Widgets;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
errorMessage: string | undefined;
version?: string;
threshold?: ReactNode;

View File

@@ -1,7 +1,8 @@
import { UseQueryResult } from 'react-query';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { SuccessResponse } from 'types/api';
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
export type GridValueComponentProps = {
@@ -12,7 +13,10 @@ export type GridValueComponentProps = {
thresholds?: ThresholdProps[];
// Context menu related props
widget?: Widgets;
queryResponse?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
contextLinks?: ContextLinksData;
enableDrillDown?: boolean;
};

View File

@@ -8,17 +8,16 @@ import { Tabs } from '@signozhq/ui/tabs';
import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import {
getListAccountServicesMetadataQueryKey,
invalidateGetAccountService,
invalidateListAccountServicesMetadata,
useGetAccountService,
getListServicesMetadataQueryKey,
invalidateGetService,
invalidateListServicesMetadata,
useGetService,
useUpdateService,
} from 'api/generated/services/cloudintegration';
import {
CloudintegrationtypesServiceConfigDTO,
CloudintegrationtypesServiceDTO,
ListAccountServicesMetadata200,
ListServicesMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
@@ -119,50 +118,30 @@ function ServiceDetails({
const cloudAccountId = urlQuery.get('cloudAccountId');
const serviceId = urlQuery.get('service');
const isReadOnly = !cloudAccountId;
const serviceQueryParams = cloudAccountId
? { cloud_integration_id: cloudAccountId }
: undefined;
const {
queryKey: _accountServiceQueryKey,
data: accountServiceData,
isLoading: isAccountServiceLoading,
} = useGetAccountService(
{
cloudProvider: type,
id: cloudAccountId || '',
serviceId: serviceId || '',
},
{
query: {
enabled: !!serviceId && !!cloudAccountId,
select: (response): ServiceDetailsData => response.data,
},
},
);
const {
queryKey: _readOnlyServiceQueryKey,
data: readOnlyServiceData,
isLoading: isReadOnlyServiceLoading,
queryKey: _queryKey,
data: serviceDetailsData,
isLoading: isServiceDetailsLoading,
} = useGetService(
{
cloudProvider: type,
serviceId: serviceId || '',
},
undefined,
{
...serviceQueryParams,
},
{
query: {
enabled: !!serviceId && !cloudAccountId,
enabled: !!serviceId,
select: (response): ServiceDetailsData => response.data,
},
},
);
const serviceDetailsData = cloudAccountId
? accountServiceData
: readOnlyServiceData;
const isServiceDetailsLoading = cloudAccountId
? isAccountServiceLoading
: isReadOnlyServiceLoading;
const integrationConfig =
type === IntegrationType.AWS_SERVICES
? serviceDetailsData?.cloudIntegrationService?.config?.aws
@@ -261,12 +240,16 @@ function ServiceDetails({
// instead of waiting for the refetch to complete.
reset(nextFormValues);
const servicesListQueryKey = getListAccountServicesMetadataQueryKey({
cloudProvider: type,
id: cloudAccountId,
});
const servicesListQueryKey = getListServicesMetadataQueryKey(
{
cloudProvider: type,
},
{
cloud_integration_id: cloudAccountId,
},
);
queryClient.setQueryData<ListAccountServicesMetadata200 | undefined>(
queryClient.setQueryData<ListServicesMetadata200 | undefined>(
servicesListQueryKey,
(prev) => {
if (!prev?.data?.services?.length) {
@@ -289,16 +272,26 @@ function ServiceDetails({
},
);
invalidateGetAccountService(queryClient, {
cloudProvider: type,
id: cloudAccountId,
serviceId,
});
invalidateGetService(
queryClient,
{
cloudProvider: type,
serviceId,
},
{
cloud_integration_id: cloudAccountId,
},
);
invalidateListAccountServicesMetadata(queryClient, {
cloudProvider: type,
id: cloudAccountId,
});
invalidateListServicesMetadata(
queryClient,
{
cloudProvider: type,
},
{
cloud_integration_id: cloudAccountId,
},
);
logEvent(`${type} Integration: Service settings saved`, {
cloudAccountId,

View File

@@ -64,7 +64,7 @@ describe('ServiceDetails for S3 Sync service', () => {
(_req, res, ctx) => res(ctx.json(accountsResponse)),
),
rest.get(
'http://localhost/api/v1/cloud_integrations/aws/accounts/:accountId/services/:serviceId',
'http://localhost/api/v1/cloud_integrations/aws/services/:serviceId',
(req, res, ctx) =>
res(
ctx.json(

View File

@@ -32,7 +32,7 @@ const accountsResponse: ListAccounts200 = {
},
};
/** Response shape for GET /cloud_integrations/aws/accounts/:accountId/services/:serviceId (used by ServiceDetails). */
/** Response shape for GET /cloud_integrations/aws/services/:serviceId (used by ServiceDetails). */
const buildServiceDetailsResponse = (
serviceId: string,
initialConfigLogsS3Buckets: Record<string, string[]> = {},
@@ -55,6 +55,7 @@ const buildServiceDetailsResponse = (
},
},
},
telemetryCollectionStrategy: { aws: {} },
},
});

View File

@@ -1,68 +0,0 @@
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,11 +53,6 @@
}
}
&.aws-service-dashboard-item-disabled {
cursor: not-allowed;
opacity: 0.6;
}
.aws-service-dashboard-item-content {
display: flex;
flex-direction: column;

View File

@@ -1,9 +1,11 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
CloudintegrationtypesServiceDashboardDTO,
CloudintegrationtypesDashboardDTO,
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({
@@ -14,6 +16,7 @@ function ServiceDashboards({
isInteractive?: boolean;
}): JSX.Element {
const dashboards = service?.assets?.dashboards || [];
const { safeNavigate } = useSafeNavigate();
if (!dashboards.length) {
return <></>;
}
@@ -22,20 +25,68 @@ function ServiceDashboards({
<div className="aws-service-dashboards">
<div className="aws-service-dashboards-title">Dashboards</div>
<div className="aws-service-dashboards-items">
{dashboards.map(
(dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => {
const key =
dashboard.integrationDashboard?.dashboardId ||
`${dashboard.title}-${index}`;
return (
<DashboardCard
key={key}
dashboard={dashboard}
isInteractive={isInteractive}
/>
);
},
)}
{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>
);
})}
</div>
</div>
);

View File

@@ -1,11 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Skeleton } from 'antd';
import {
useListAccounts,
useListAccountServicesMetadata,
useListServicesMetadata,
} from 'api/generated/services/cloudintegration';
import { 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';
@@ -24,33 +20,17 @@ function ServicesList({
}: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const isAccountConnected = Boolean(cloudAccountId);
const { data: listAccountsResponse, isLoading: isAccountsLoading } =
useListAccounts({ cloudProvider: type });
const hasConnectedAccounts =
(listAccountsResponse?.data?.accounts?.length ?? 0) > 0;
const hasValidCloudAccountId = Boolean(cloudAccountId);
const serviceQueryParams = hasValidCloudAccountId
? { cloud_integration_id: cloudAccountId }
: undefined;
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 { data: servicesMetadata, isLoading } = useListServicesMetadata(
{
cloudProvider: type,
},
serviceQueryParams,
);
const awsServices = useMemo(
() => servicesMetadata?.data?.services ?? [],

View File

@@ -89,7 +89,7 @@ export function AlertsEmptyState({
onClick={onClickNewAlertHandler}
disabled={!addNewAlert}
loading={loading}
testId="add-alert"
data-testid="add-alert"
>
<span className={styles.buttonContent}>
<Plus size="md" />
@@ -97,12 +97,7 @@ export function AlertsEmptyState({
</span>
</Button>
{onRefresh && (
<Button
onClick={onRefresh}
prefix={<RefreshCw />}
color="secondary"
testId="list-alerts-empty-refresh-button"
>
<Button onClick={onRefresh} prefix={<RefreshCw />} color="secondary">
Refresh
</Button>
)}

View File

@@ -1,215 +0,0 @@
import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { findAlertRow, renderListAlertRules } from './_helpers';
async function openActionsMenu(row: HTMLElement): Promise<void> {
const trigger = row.querySelector(
'[data-testid="alert-actions"]',
) as HTMLElement | null;
expect(trigger).not.toBeNull();
const user = userEvent.setup({ delay: null });
await user.click(trigger as HTMLElement);
// Radix renders the menu items in a portal once the trigger is activated.
await screen.findByRole('menu');
}
async function clickMenuItem(label: string): Promise<void> {
const user = userEvent.setup({ delay: null });
const item = await screen.findByRole('menuitem', { name: label });
await user.click(item);
}
describe('ListAlertRules — actions menu', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders Enable/Disable/Edit/Edit in New Tab/Clone/Delete items after opening the menu', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await openActionsMenu(row);
const items = screen.getAllByRole('menuitem');
const labels = items.map((it) => it.textContent);
expect(labels).toStrictEqual(
expect.arrayContaining([
'Edit',
'Edit in New Tab',
'Clone',
'Delete',
'Disable',
]),
);
});
it('disabled rule (rule-4) shows "Enable" instead of "Disable"', async () => {
renderListAlertRules();
const row = await findAlertRow('Disabled Alert');
await openActionsMenu(row);
const items = screen.getAllByRole('menuitem');
const labels = items.map((it) => it.textContent);
expect(labels).toContain('Enable');
expect(labels).not.toContain('Disable');
});
it('toggle action: clicking Disable sends PATCH with disabled:true', async () => {
let capturedBody: unknown = null;
let capturedPath: string | null = null;
server.use(
rest.patch('http://localhost/api/v2/rules/:id', async (req, res, ctx) => {
capturedBody = await req.json();
capturedPath = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Disable');
await waitFor(() => {
expect(capturedBody).toStrictEqual(
expect.objectContaining({ disabled: true }),
);
});
expect(capturedPath).toBe('rule-1');
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
);
});
it('edit action: clicking Edit navigates via safeNavigate and logs event', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Edit');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock.mock.calls[0][0]).toContain('ruleId=rule-1');
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Edit', ruleId: 'rule-1' }),
);
});
it('edit in new tab action: clicking opens with newTab:true', async () => {
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Edit in New Tab');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url, options] = safeNavigateMock.mock.calls[0];
expect(url).toContain('ruleId=rule-1');
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
server.use(
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
});
// The id from the server response round-trips into the navigate URL — this
// protects against a regression where the code hardcodes the id.
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock.mock.calls[0][0]).toContain(
'ruleId=cloned-from-server',
);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Clone', ruleId: 'rule-1' }),
);
});
it('delete action: sends DELETE for the rule id', async () => {
let deletedId: string | null = null;
server.use(
rest.delete('http://localhost/api/v2/rules/:id', (req, res, ctx) => {
deletedId = req.params.id as string;
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Delete');
await waitFor(() => {
expect(deletedId).toBe('rule-1');
});
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Delete', ruleId: 'rule-1' }),
);
});
it('error path: PATCH is still attempted when server returns 500', async () => {
let patchAttempted = false;
server.use(
rest.patch('http://localhost/api/v2/rules/:id', (_, res, ctx) => {
patchAttempted = true;
return res(ctx.status(500), ctx.json({ status: 'error' }));
}),
);
renderListAlertRules();
const row = await findAlertRow('High CPU Alert');
await openActionsMenu(row);
await clickMenuItem('Disable');
await waitFor(() => {
expect(patchAttempted).toBe(true);
});
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable', ruleId: 'rule-1' }),
);
});
});

View File

@@ -1,79 +0,0 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
const COLUMN_STORAGE_KEY = '@signoz/table-columns/alert-rules-columns';
describe('ListAlertRules — columns selector', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('opens columns popover and lists toggleable columns', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByTestId('alert-columns-button'));
// Popover should reveal "Toggle Columns" heading + per-column labels.
await screen.findByText('Toggle Columns');
expect(screen.getByText('Created At')).toBeInTheDocument();
expect(screen.getByText('Created By')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Updated By')).toBeInTheDocument();
});
it('default-hidden columns (Created At/By, Updated At/By) are not in the table header', async () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
const headers = document.querySelectorAll('th');
const headerTexts = Array.from(headers).map((h) => h.textContent || '');
expect(headerTexts.some((t) => t.includes('Created At'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Created By'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Updated At'))).toBe(false);
expect(headerTexts.some((t) => t.includes('Updated By'))).toBe(false);
});
it('toggling Created At on writes to localStorage and adds the header', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const headersBefore = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headersBefore.some((t) => t.includes('Created At'))).toBe(false);
await user.click(screen.getByTestId('alert-columns-button'));
await screen.findByText('Toggle Columns');
const checkbox = document.getElementById('col-createdAt');
expect(checkbox).not.toBeNull();
await user.click(checkbox as HTMLElement);
await waitFor(() => {
const stored = window.localStorage.getItem(COLUMN_STORAGE_KEY);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored as string);
expect(parsed.hiddenColumnIds).not.toContain('createdAt');
});
await waitFor(() => {
const headersAfter = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headersAfter.some((t) => t.includes('Created At'))).toBe(true);
});
});
});

View File

@@ -1,91 +0,0 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — empty states', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders AlertsEmptyState when API returns no rules', async () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
),
);
renderListAlertRules();
await screen.findByText('No Alert rules yet.');
expect(
screen.getByText('Create an Alert Rule to get started'),
).toBeInTheDocument();
// New Alert Rule button is visible and triggers safeNavigate to ALERTS_NEW.
await user.click(screen.getByTestId('add-alert'));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('renders ErrorEmptyState when API returns 500; refresh triggers a refetch', async () => {
const user = userEvent.setup({ delay: null });
let callCount = 0;
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
callCount += 1;
if (callCount === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({ data: alertRulesFixture, status: 'success' }),
);
}),
);
renderListAlertRules();
await screen.findByTestId('error-empty-state');
await user.click(screen.getByTestId('error-refresh-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
});
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const searchInput = screen.getByTestId('list-alerts-search-input');
await user.clear(searchInput);
await user.type(searchInput, 'totally-not-found');
await screen.findByTestId('no-results-empty-state');
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'No alert rules match your search. Try adjusting your search criteria.',
);
await user.click(screen.getByTestId('no-results-clear-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
});
});

View File

@@ -1,123 +0,0 @@
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — list rendering', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('renders alert rules from API', async () => {
renderListAlertRules();
await expect(
screen.findByTestId('alert-row-rule-1-name'),
).resolves.toHaveTextContent('High CPU Alert');
expect(screen.getByTestId('alert-row-rule-2-name')).toHaveTextContent(
'Memory Pending Alert',
);
expect(screen.getByTestId('alert-row-rule-3-name')).toHaveTextContent(
'Healthy Alert',
);
expect(screen.getByTestId('alert-row-rule-4-name')).toHaveTextContent(
'Disabled Alert',
);
});
it('renders state badges via STATE_CONFIG mapping', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveTextContent(
'Firing',
);
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveTextContent(
'Pending',
);
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveTextContent('OK');
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveTextContent(
'Disabled',
);
expect(screen.getByTestId('alert-row-rule-5-state')).toHaveTextContent('OK');
});
it('renders state badges with semantic colors', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-state')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-state')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-rule-2-state')).toHaveAttribute(
'data-color',
'amber',
);
expect(screen.getByTestId('alert-row-rule-3-state')).toHaveAttribute(
'data-color',
'forest',
);
expect(screen.getByTestId('alert-row-rule-4-state')).toHaveAttribute(
'data-color',
'vanilla',
);
});
it('renders severity badges for rules with severity', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-severity')).toBeInTheDocument(),
);
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveTextContent(
'critical',
);
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveTextContent(
'warning',
);
expect(screen.getByTestId('alert-row-rule-3-severity')).toHaveTextContent(
'info',
);
expect(screen.getByTestId('alert-row-rule-4-severity')).toHaveTextContent(
'critical',
);
expect(screen.getByTestId('alert-row-rule-5-severity')).toHaveTextContent(
'-',
);
expect(screen.getByTestId('alert-row-rule-1-severity')).toHaveAttribute(
'data-color',
'cherry',
);
expect(screen.getByTestId('alert-row-rule-2-severity')).toHaveAttribute(
'data-color',
'amber',
);
});
it('renders header controls (search, columns, new alert)', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
);
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
).toBeInTheDocument();
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
});
});

View File

@@ -1,65 +0,0 @@
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — new alert button', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('plain click navigates to ALERTS_NEW with newTab:false', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: false }),
);
});
it('logs Alert: New alert button clicked', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await waitFor(() => {
expect(logEventMock).toHaveBeenCalledWith(
'Alert: New alert button clicked',
expect.objectContaining({ layout: 'new' }),
);
});
});
it('ctrl+click on New Alert opens in a new tab (newTab:true)', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.keyboard('{Control>}');
await user.click(screen.getByRole('button', { name: /new alert/i }));
await user.keyboard('{/Control}');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.ALERTS_NEW,
expect.objectContaining({ newTab: true }),
);
});
});

View File

@@ -1,64 +0,0 @@
import userEvent from '@testing-library/user-event';
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — pagination', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
),
),
);
});
it('shows first 10 rows on page 1 (default limit)', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
for (let i = 0; i < 10; i += 1) {
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
}
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
});
it('shows total count when showTotalCount is enabled', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const totalCount = await screen.findByTestId('pagination-total-count');
expect(totalCount.textContent).toContain('Showing');
expect(totalCount.textContent).toContain('of 15');
});
it('navigates to page 2 and shows remaining rows', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const nextBtn = screen.getByLabelText('Go to next page');
await user.click(nextBtn);
await waitFor(() => {
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
expect(screen.getByText('Pag Rule 14')).toBeInTheDocument();
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(getCurrentNuqsQueryString()).toContain('page=2');
});
});
});

View File

@@ -1,71 +0,0 @@
import { screen, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — permissions', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('VIEWER role hides "New Alert" button and "Actions" column', async () => {
renderListAlertRules({ role: USER_ROLES.VIEWER });
await screen.findByText('High CPU Alert');
expect(
screen.queryByTestId('list-alerts-new-alert-button'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /new alert/i }),
).not.toBeInTheDocument();
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(false);
expect(screen.queryByTestId('alert-actions')).not.toBeInTheDocument();
});
it('ADMIN role shows "New Alert" button and "Actions" column', async () => {
renderListAlertRules({ role: USER_ROLES.ADMIN });
await screen.findByText('High CPU Alert');
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
await waitFor(() => {
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
});
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
});
it('EDITOR role behaves like ADMIN (New Alert + Actions visible)', async () => {
renderListAlertRules({ role: USER_ROLES.EDITOR });
await screen.findByText('High CPU Alert');
expect(
screen.getByTestId('list-alerts-new-alert-button'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /new alert/i }),
).toBeInTheDocument();
await waitFor(() => {
const headers = Array.from(document.querySelectorAll('th')).map(
(h) => h.textContent ?? '',
);
expect(headers.some((t) => t.includes('Actions'))).toBe(true);
});
expect(screen.getAllByTestId('alert-actions').length).toBeGreaterThan(0);
});
});

View File

@@ -1,52 +0,0 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — row click navigation', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
expect(td).not.toBeNull();
await user.click(td as HTMLElement);
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
});
it('ctrl+click on a row navigates with newTab option', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
const ruleCell = await screen.findByText('High CPU Alert');
const td = ruleCell.closest('td');
await user.keyboard('{Control>}');
await user.click(td as HTMLElement);
await user.keyboard('{/Control}');
await waitFor(() => {
expect(safeNavigateMock).toHaveBeenCalled();
});
const [url, options] = safeNavigateMock.mock.calls[0];
expect(url).toContain('ruleId=rule-1');
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
});

View File

@@ -1,99 +0,0 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
function getSearchInput(): HTMLInputElement {
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
}
describe('ListAlertRules — search', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('filters rows by alert name with debounce', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
});
});
it('filters rows by label values (severity)', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'warning');
await waitFor(() => {
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
});
});
it('restores all rows when search is cleared', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
await waitFor(() => {
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
});
await user.clear(getSearchInput());
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
});
});
it('shows no-results state when no match', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'zzzzzz-no-match');
await waitFor(() => {
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
});
});
it('resets page to 1 when search debounce fires', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({ initialRoute: '/?page=2' });
// Page 2 of the 4-rule fixture has no rows; we only need the search input
// to be mounted, which happens before data is fetched.
const input = await screen.findByTestId('list-alerts-search-input');
await user.clear(input);
await user.type(input, 'CPU');
await waitFor(() => {
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
});
});
});

View File

@@ -1,232 +0,0 @@
import { logEventMock } from '__tests__/logEventMock';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertRule } from '../types';
import {
ALERT_ACTIONS,
alertActionLogEvent,
filterRulesByFilters,
getAlertSortValue,
sortRules,
} from '../utils';
const baseRule = {
id: 'r1',
alert: 'Rule 1',
alertType: 'METRIC_BASED_ALERT',
state: 'inactive',
labels: { severity: 'info' },
condition: {},
createdAt: '2023-10-15T10:00:00Z',
updatedAt: '2023-10-19T10:00:00Z',
} as unknown as AlertRule;
const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
...baseRule,
...overrides,
});
describe('getAlertSortValue', () => {
it('returns state for "state"', () => {
expect(
getAlertSortValue(
makeRule({ state: RuletypesAlertStateDTO.firing }),
'state',
),
).toBe('firing');
});
it('returns alert name for "name"', () => {
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
'My Rule',
);
});
it('returns severity label for "severity"', () => {
expect(
getAlertSortValue(
makeRule({ labels: { severity: 'critical' } }),
'severity',
),
).toBe('critical');
});
it('returns createdAt as ms', () => {
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
const result = getAlertSortValue(rule, 'createdAt');
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
});
it('returns updatedAt as ms', () => {
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
const result = getAlertSortValue(rule, 'updatedAt');
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
});
it('returns 0 when createdAt missing', () => {
expect(
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
).toBe(0);
});
it('returns empty for unknown column', () => {
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
});
it('returns empty for missing fields', () => {
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'state',
),
).toBe('');
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'severity',
),
).toBe('');
});
});
describe('sortRules', () => {
const r1 = makeRule({ id: '1', alert: 'A' });
const r2 = makeRule({ id: '2', alert: 'B' });
const r3 = makeRule({ id: '3', alert: 'C' });
it('sorts ascending by name', () => {
const order: SortState = { columnName: 'name', order: 'asc' };
const result = sortRules([r3, r1, r2], order);
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
});
it('sorts descending by name', () => {
const order: SortState = { columnName: 'name', order: 'desc' };
const result = sortRules([r1, r2, r3], order);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
});
it('returns unsorted when orderBy is null', () => {
const result = sortRules([r3, r1, r2], null);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
});
});
describe('filterRulesByFilters', () => {
const r1 = makeRule({
id: '1',
alert: 'R1',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'critical' },
});
const r2 = makeRule({
id: '2',
alert: 'R2',
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'warning' },
});
const r3 = makeRule({
id: '3',
alert: 'R3',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'warning' },
});
const rules = [r1, r2, r3];
it('returns input when filters empty', () => {
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
});
it('filters by state', () => {
const result = filterRulesByFilters(rules, ['state:firing']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
});
it('filters by severity', () => {
const result = filterRulesByFilters(rules, ['severity:warning']);
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
});
it('combines state AND severity', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'severity:warning',
]);
expect(result.map((r) => r.id)).toStrictEqual(['3']);
});
it('OR within same key (state)', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'state:inactive',
]);
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
});
it('matches values case-insensitively', () => {
const result = filterRulesByFilters(rules, ['state:FIRING']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
});
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
expect(result).toStrictEqual(rules);
});
it('returns empty when no rule matches', () => {
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
});
it('ignores unknown prefix', () => {
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
});
});
describe('alertActionLogEvent', () => {
it('logs with mapped action label', () => {
const rule = makeRule({
id: 'rule-1',
alert: 'My Rule',
alertType: 'METRIC_BASED_ALERT' as AlertRule['alertType'],
});
alertActionLogEvent(ALERT_ACTIONS.EDIT, rule);
expect(logEventMock).toHaveBeenCalledWith('Alert: Action', {
ruleId: 'rule-1',
dataSource: expect.any(String),
name: 'My Rule',
action: 'Edit',
});
});
it('falls back to raw action when unmapped', () => {
alertActionLogEvent('custom', baseRule);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'custom' }),
);
});
it('maps TOGGLE action', () => {
alertActionLogEvent(ALERT_ACTIONS.TOGGLE, baseRule);
expect(logEventMock).toHaveBeenCalledWith(
'Alert: Action',
expect.objectContaining({ action: 'Enable/Disable' }),
);
});
it('maps DELETE and CLONE', () => {
alertActionLogEvent(ALERT_ACTIONS.DELETE, baseRule);
alertActionLogEvent(ALERT_ACTIONS.CLONE, baseRule);
expect(logEventMock).toHaveBeenNthCalledWith(
1,
'Alert: Action',
expect.objectContaining({ action: 'Delete' }),
);
expect(logEventMock).toHaveBeenNthCalledWith(
2,
'Alert: Action',
expect.objectContaining({ action: 'Clone' }),
);
});
});

View File

@@ -1,65 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, RenderResult, screen } from '@testing-library/react';
import ListAlertRules from 'container/ListAlertRules';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { AppContext } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
import { getAppContextMock } from 'tests/test-utils';
interface RenderOptions {
role?: string;
initialRoute?: string;
}
export function renderListAlertRules(
options: RenderOptions = {},
): RenderResult {
const { role = 'ADMIN', initialRoute = '/' } = options;
const initialSearch = initialRoute.includes('?')
? initialRoute.slice(initialRoute.indexOf('?'))
: '';
resetNuqsState(initialSearch);
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
return render(
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsTestingAdapter
searchParams={initialSearch}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock(role)}>
<TimezoneProvider>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 46 }}
>
<ListAlertRules />
</VirtuosoMockContext.Provider>
</TimezoneProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsTestingAdapter>
</MemoryRouter>,
);
}
export async function findAlertRow(alertName: string): Promise<HTMLElement> {
const cell = await screen.findByText(alertName, {}, { timeout: 5000 });
const row = cell.closest('tr');
if (!row) {
throw new Error(`Row not found for alert "${alertName}"`);
}
return row as HTMLElement;
}

View File

@@ -47,7 +47,6 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
data-testid="alert-columns-button"
>
Columns
</Button>

View File

@@ -136,7 +136,6 @@ function ListAlertRules(): JSX.Element {
prefix={<Plus size={14} />}
onClick={handleNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
@@ -158,7 +157,6 @@ function ListAlertRules(): JSX.Element {
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="list-alerts-search-input"
/>
</div>
)}

View File

@@ -26,18 +26,14 @@ export function getAlertRuleColumns(
enableSort: true,
enableRemove: false,
enableMove: false,
cell: ({ row, value }): JSX.Element => {
cell: ({ value }): JSX.Element => {
const state = String(value ?? '').toLowerCase();
const config = STATE_CONFIG[state] ?? {
color: 'secondary' as BadgeColor,
label: 'Unknown',
};
return (
<Badge
color={config.color}
variant="outline"
testId={`alert-row-${row.id ?? ''}-state`}
>
<Badge color={config.color} variant="outline">
{config.label}
</Badge>
);
@@ -51,11 +47,8 @@ export function getAlertRuleColumns(
enableSort: true,
enableRemove: false,
enableMove: false,
cell: ({ row, value }): JSX.Element => (
<TanStackTable.Text
title={value}
data-testid={`alert-row-${row.id ?? ''}-name`}
>
cell: ({ value }): JSX.Element => (
<TanStackTable.Text title={value}>
{String(value ?? '-')}
</TanStackTable.Text>
),
@@ -67,20 +60,15 @@ export function getAlertRuleColumns(
width: { fixed: '120px' },
enableSort: true,
enableMove: false,
cell: ({ row, value }): JSX.Element => {
cell: ({ value }): JSX.Element => {
const severity = String(value ?? '').toLowerCase();
if (!severity) {
return (
<TanStackTable.Text data-testid={`alert-row-${row.id ?? ''}-severity`}>
-
</TanStackTable.Text>
);
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<Badge
color={SEVERITY_BADGE_COLORS[severity] ?? 'secondary'}
variant="outline"
testId={`alert-row-${row.id ?? ''}-severity`}
>
{severity}
</Badge>

View File

@@ -232,7 +232,7 @@ function DashboardsList(): JSX.Element {
isLocked: !!e.locked || false,
lastUpdatedBy: e.updatedBy,
image: e.data.image || Base64Icons[0],
variables: e.data.variables ?? {},
variables: e.data.variables,
widgets: e.data.widgets,
layout: e.data.layout,
panelMap: e.data.panelMap,

View File

@@ -10,7 +10,7 @@ import {
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDispatch, 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,6 +42,7 @@ 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';
@@ -76,6 +77,7 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -88,9 +90,10 @@ function LogsExplorerViewsContainer({
DEFAULT_PER_PAGE_VALUE,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const currentMinTimeRef = useRef<number>(minTime);
@@ -326,6 +329,16 @@ 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: '' },
@@ -347,6 +360,8 @@ function LogsExplorerViewsContainer({
minTime,
activeLogId,
selectedPanelType,
dispatch,
selectedTime,
maxTime,
orderBy,
]);

View File

@@ -108,21 +108,13 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
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/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>

View File

@@ -1,8 +1,8 @@
import { logEventMock } from '__tests__/logEventMock';
import { render, screen, userEvent } from 'tests/test-utils';
import MCPServerSettings from './MCPServerSettings';
const mockLogEvent = jest.fn();
const mockCopyToClipboard = jest.fn();
const mockHistoryPush = jest.fn();
const mockUseGetGlobalConfig = jest.fn();
@@ -11,6 +11,11 @@ const mockUseGetTenantLicense = jest.fn();
const mockToastSuccess = jest.fn();
const mockToastWarning = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
jest.mock('api/generated/services/global', () => ({
useGetGlobalConfig: (...args: unknown[]): unknown =>
mockUseGetGlobalConfig(...args),
@@ -143,7 +148,7 @@ describe('MCPServerSettings', () => {
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(logEventMock).toHaveBeenCalledWith('MCP Settings: Page viewed', {
expect(mockLogEvent).toHaveBeenCalledWith('MCP Settings: Page viewed', {
role: 'ADMIN',
});
});

View File

@@ -1,6 +1,5 @@
import userEvent from '@testing-library/user-event';
import MySettingsContainer from 'container/MySettings';
import { logEventMock } from '__tests__/logEventMock';
import {
act,
fireEvent,
@@ -13,6 +12,7 @@ import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
const updateMyPasswordFn = jest.fn();
@@ -62,6 +62,11 @@ jest.mock('hooks/useDarkMode', () => ({
})),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn((eventName, data) => logEventFunction(eventName, data)),
}));
const errorNotification = jest.fn();
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
@@ -130,7 +135,7 @@ describe('MySettings Flows', () => {
await waitFor(() => {
expect(toggleThemeFunction).toHaveBeenCalled();
expect(logEventMock).toHaveBeenCalledWith(
expect(logEventFunction).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{
theme: 'light',

View File

@@ -28,8 +28,9 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import { UpdateTimeInterval } from 'store/actions';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
function WidgetGraph({
@@ -201,7 +202,10 @@ function WidgetGraph({
interface WidgetGraphProps {
selectedWidget: Widgets;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;

View File

@@ -1,12 +1,11 @@
import { QueryRangeRequestV5 } from 'api/v5/v5';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Column, QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
// eslint-disable-next-line sonarjs/cognitive-complexity
export function populateMultipleResults(
responseData: SuccessResponse<MetricRangePayloadProps, QueryRangeRequestV5>,
): SuccessResponse<MetricRangePayloadProps, QueryRangeRequestV5> {
responseData: SuccessResponse<MetricRangePayloadProps, unknown>,
): SuccessResponse<MetricRangePayloadProps, unknown> {
const queryResults = responseData?.payload?.data?.newResult?.data?.result;
const allFormattedResults: QueryData[] = [];
@@ -67,19 +66,17 @@ export function populateMultipleResults(
}
// Create a copy instead of mutating the original
const updatedResponseData: SuccessResponse<
MetricRangePayloadProps,
QueryRangeRequestV5
> = {
...responseData,
payload: {
...responseData.payload,
data: {
...responseData.payload.data,
result: allFormattedResults,
const updatedResponseData: SuccessResponse<MetricRangePayloadProps, unknown> =
{
...responseData,
payload: {
...responseData.payload,
data: {
...responseData.payload.data,
result: allFormattedResults,
},
},
},
};
};
return updatedResponseData;
}

Some files were not shown because too many files have changed in this diff Show More