Compare commits

..

5 Commits

Author SHA1 Message Date
Naman Verma
dbf57cf28c chore: change panel background to a hexcode string 2026-09-05 01:58:16 +05:30
Naman Verma
8f5f7fe23b Merge branch 'main' into nv/text-panel 2026-09-05 01:28:09 +05:30
Naman Verma
edf7c74097 feat: add header options to text panel 2026-08-31 14:55:27 +05:30
Naman Verma
40c7794d0d Merge branch 'main' into nv/text-panel 2026-08-31 14:40:39 +05:30
Naman Verma
ca618573cb feat: add spec for text panel 2026-08-27 17:59:47 +05:30
159 changed files with 2778 additions and 8443 deletions

View File

@@ -97,7 +97,6 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,

View File

@@ -397,6 +397,7 @@ identn:
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
enabled: true

View File

@@ -25,379 +25,6 @@ components:
- data
- orgId
type: object
AlertmanagertypesChannelConfig:
discriminator:
mapping:
email: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
googlechat: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
incidentio: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
jira: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
jsmops: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
msteams: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
properties:
kind:
enum:
- email
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelEmailConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig:
properties:
kind:
enum:
- googlechat
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelGoogleChatConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig:
properties:
kind:
enum:
- incidentio
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelIncidentIOConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig:
properties:
kind:
enum:
- jsmops
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelJSMOpsConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig:
properties:
kind:
enum:
- jira
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelJiraConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig:
properties:
kind:
enum:
- msteams
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelMSTeamsConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig:
properties:
kind:
enum:
- opsgenie
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelOpsgenieConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig:
properties:
kind:
enum:
- pagerduty
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelPagerdutyConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig:
properties:
kind:
enum:
- slack
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
properties:
kind:
enum:
- webhook
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelWebhookConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelEmailConfig:
properties:
headers:
additionalProperties:
type: string
type: object
html:
type: string
sendResolved:
nullable: true
type: boolean
to:
type: string
required:
- to
type: object
AlertmanagertypesChannelGoogleChatConfig:
properties:
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
webhookUrl:
type: string
required:
- webhookUrl
type: object
AlertmanagertypesChannelIncidentIOConfig:
properties:
description:
type: string
metadata:
additionalProperties:
type: string
type: object
sendResolved:
nullable: true
type: boolean
title:
type: string
token:
type: string
url:
type: string
required:
- url
- token
type: object
AlertmanagertypesChannelJSMOpsConfig:
properties:
apiKey:
type: string
description:
type: string
message:
type: string
priority:
type: string
sendResolved:
nullable: true
type: boolean
tags:
type: string
required:
- apiKey
type: object
AlertmanagertypesChannelJiraConfig:
properties:
apiToken:
type: string
customFields:
additionalProperties: {}
type: object
description:
type: string
email:
type: string
issueType:
type: string
labels:
items:
type: string
type: array
priority:
type: string
project:
type: string
reopenDuration:
type: string
reopenTransition:
type: string
resolveTransition:
type: string
sendResolved:
nullable: true
type: boolean
site:
type: string
summary:
type: string
wontFixResolution:
type: string
required:
- site
- project
- issueType
- email
- apiToken
type: object
AlertmanagertypesChannelKind:
enum:
- slack
- email
- webhook
- pagerduty
- opsgenie
- msteams
- googlechat
- jira
- jsmops
- incidentio
type: string
AlertmanagertypesChannelMSTeamsConfig:
properties:
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
webhookUrl:
type: string
required:
- webhookUrl
type: object
AlertmanagertypesChannelOpsgenieConfig:
properties:
apiKey:
type: string
apiUrl:
type: string
description:
type: string
details:
additionalProperties:
type: string
type: object
message:
type: string
priority:
type: string
sendResolved:
nullable: true
type: boolean
source:
type: string
required:
- apiKey
type: object
AlertmanagertypesChannelPagerdutyConfig:
properties:
class:
type: string
client:
type: string
clientUrl:
type: string
component:
type: string
description:
type: string
details:
additionalProperties:
type: string
type: object
group:
type: string
routingKey:
type: string
sendResolved:
nullable: true
type: boolean
severity:
type: string
source:
type: string
url:
type: string
required:
- routingKey
type: object
AlertmanagertypesChannelSlackConfig:
properties:
apiUrl:
type: string
channel:
type: string
sendResolved:
nullable: true
type: boolean
text:
type: string
title:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
type: string
password:
type: string
sendResolved:
nullable: true
type: boolean
url:
type: string
username:
type: string
required:
- url
type: object
AlertmanagertypesDeprecatedGettableAlert:
properties:
annotations:
@@ -427,30 +54,6 @@ components:
- rule
- policy
type: string
AlertmanagertypesGettableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
createdAt:
format: date-time
type: string
displayName:
type: string
id:
type: string
name:
type: string
updatedAt:
format: date-time
type: string
required:
- name
- displayName
- config
- id
- createdAt
- updatedAt
type: object
AlertmanagertypesGettableRoutePolicy:
properties:
channels:
@@ -753,19 +356,6 @@ components:
required:
- name
type: object
AlertmanagertypesPostableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
displayName:
type: string
generateName:
type: boolean
name:
type: string
required:
- config
type: object
AlertmanagertypesPostablePlannedMaintenance:
properties:
alertIds:
@@ -3474,6 +3064,11 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3831,6 +3426,7 @@ components:
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
@@ -3841,6 +3437,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3851,6 +3448,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3924,6 +3522,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
properties:
kind:
enum:
- signoz/TextPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
properties:
kind:
@@ -4215,6 +3825,37 @@ components:
- color
- columnName
type: object
DashboardtypesTextAlign:
enum:
- left
- center
- right
type: string
DashboardtypesTextMode:
enum:
- markdown
type: string
DashboardtypesTextPanelSpec:
properties:
headerOptions:
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
mode:
$ref: '#/components/schemas/DashboardtypesTextMode'
presentation:
$ref: '#/components/schemas/DashboardtypesTextPresentation'
text:
type: string
type: object
DashboardtypesTextPresentation:
properties:
background:
nullable: true
type: string
textAlign:
$ref: '#/components/schemas/DashboardtypesTextAlign'
verticalAlign:
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
@@ -4424,6 +4065,12 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:
@@ -19719,69 +19366,6 @@ paths:
summary: Get metrics treemap
tags:
- metrics
/api/v2/notification_channels:
post:
deprecated: false
description: This endpoint creates a notification channel
operationId: CreateNotificationChannel
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesPostableNotificationChannel'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:create
- tokenizer:
- notification-channel:create
summary: Create notification channel
tags:
- channels
/api/v2/orgs/me:
get:
deprecated: false
@@ -20308,9 +19892,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- role:read
- ADMIN
- tokenizer:
- role:read
- ADMIN
summary: Get users by role id
tags:
- users
@@ -24872,11 +24456,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:attach
- role:attach
- ADMIN
- tokenizer:
- user:attach
- role:attach
- ADMIN
summary: Create user role
tags:
- users
@@ -24926,11 +24508,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:detach
- role:detach
- ADMIN
- tokenizer:
- user:detach
- role:detach
- ADMIN
summary: Delete user role
tags:
- users
@@ -24991,9 +24571,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:read
- ADMIN
- tokenizer:
- user:read
- ADMIN
summary: Get user role
tags:
- users
@@ -25039,9 +24619,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:list
- ADMIN
- tokenizer:
- user:list
- ADMIN
summary: List users v2
tags:
- users
@@ -25101,13 +24681,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:create
- user:attach
- role:attach
- ADMIN
- tokenizer:
- user:create
- user:attach
- role:attach
- ADMIN
summary: Create user
tags:
- users
@@ -25151,9 +24727,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:delete
- ADMIN
- tokenizer:
- user:delete
- ADMIN
summary: Delete user
tags:
- users
@@ -25208,9 +24784,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:read
- ADMIN
- tokenizer:
- user:read
- ADMIN
summary: Get user by user id
tags:
- users
@@ -25264,9 +24840,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:update
- ADMIN
- tokenizer:
- user:update
- ADMIN
summary: Update user v2
tags:
- users
@@ -25322,9 +24898,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- factor-password:list
- ADMIN
- tokenizer:
- factor-password:list
- ADMIN
summary: Get reset password token for a user
tags:
- users
@@ -25387,11 +24963,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- factor-password:create
- user:attach
- ADMIN
- tokenizer:
- factor-password:create
- user:attach
- ADMIN
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -25449,9 +25023,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- user:read
- ADMIN
- tokenizer:
- user:read
- ADMIN
summary: Get user roles
tags:
- users

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). If the target list is optional in the payload (e.g. `userRoles` at user creation), set `OptionalTargets`: when no target ids resolve, the attach is vacuous and the def is skipped entirely — without it, the empty-id contract would check collection-level access on the target. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -0,0 +1,85 @@
package httplicensing
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type licensingAPI struct {
licensing licensing.Licensing
}
func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
return &licensingAPI{licensing: licensing}
}
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableSubscription)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(rw, err)
return
}
gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableSubscription)
}
func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
req := new(licensetypes.PostableSubscription)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(rw, err)
return
}
gettableSubscription, err := api.licensing.Portal(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettableSubscription)
}

View File

@@ -2,9 +2,12 @@ package httplicensing
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/tidwall/gjson"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -225,6 +228,47 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
return nil
}
func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal checkout payload")
}
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal portal payload")
}
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error) {
license, err := provider.GetActive(ctx, organizationID)
if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
@@ -41,6 +42,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
IntegrationsController: opts.IntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)
@@ -70,6 +72,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)

View File

@@ -0,0 +1,76 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/SigNoz/signoz/ee/query-service/model"
)
type DayWiseBreakdown struct {
Type string `json:"type"`
Breakdown []DayWiseData `json:"breakdown"`
}
type DayWiseData struct {
Timestamp int64 `json:"timestamp"`
Count float64 `json:"count"`
Size float64 `json:"size"`
UnitPrice float64 `json:"unitPrice"`
Quantity float64 `json:"quantity"`
Total float64 `json:"total"`
}
type tierBreakdown struct {
UnitPrice float64 `json:"unitPrice"`
Quantity float64 `json:"quantity"`
TierStart int64 `json:"tierStart"`
TierEnd int64 `json:"tierEnd"`
TierCost float64 `json:"tierCost"`
}
type usageResponse struct {
Type string `json:"type"`
Unit string `json:"unit"`
Tiers []tierBreakdown `json:"tiers"`
DayWiseBreakdown DayWiseBreakdown `json:"dayWiseBreakdown"`
}
type details struct {
Total float64 `json:"total"`
Breakdown []usageResponse `json:"breakdown"`
BaseFee float64 `json:"baseFee"`
BillTotal float64 `json:"billTotal"`
}
type billingData struct {
BillingPeriodStart int64 `json:"billingPeriodStart"`
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
Details details `json:"details"`
Discount float64 `json:"discount"`
SubscriptionStatus string `json:"subscriptionStatus"`
}
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
licenseKey := r.URL.Query().Get("licenseKey")
if licenseKey == "" {
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
return
}
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
if err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
var billing billingData
if err := json.Unmarshal(data, &billing); err != nil {
RespondError(w, model.InternalError(err), nil)
return
}
ah.Respond(w, billing)
}

View File

@@ -184,6 +184,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterWebSocketPaths(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
@@ -196,7 +197,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
})
handler := c.Handler(r)

View File

@@ -169,12 +169,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
// Check for workspace blocked (trial expired)
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
const isRouteEnabledForWorkspaceBlockedState =
pathname === ROUTES.SETTINGS ||
pathname === ROUTES.BILLING ||
(isAdmin &&
(pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.MY_SETTINGS));
isAdmin &&
(pathname === ROUTES.SETTINGS ||
pathname === ROUTES.ORG_SETTINGS ||
pathname === ROUTES.MEMBERS_SETTINGS ||
pathname === ROUTES.BILLING ||
pathname === ROUTES.MY_SETTINGS);
if (
pathname !== ROUTES.WORKSPACE_LOCKED &&

View File

@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.MY_SETTINGS);
});
it('should allow VIEWER to access /settings when workspace is blocked', () => {
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.SETTINGS);
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
renderPrivateRoute({
initialRoute: ROUTES.BILLING,
appContext: {
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.BILLING);
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should redirect VIEWER to workspace locked when trying to access org-settings', async () => {
@@ -819,7 +819,7 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should allow EDITOR to access /settings when workspace is blocked', () => {
it('should redirect EDITOR to workspace locked when trying to access settings', async () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
assertStaysOnRoute(ROUTES.SETTINGS);
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
it('should not redirect when already on workspace locked page', () => {
@@ -1626,7 +1626,6 @@ describe('PrivateRoute', () => {
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
deniedRoles: DENIED_ROLES,
},
BILLING: { path: ROUTES.BILLING, deniedRoles: DENIED_ROLES },
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(

View File

@@ -0,0 +1,59 @@
import axios from 'api';
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;
details: {
total: number;
baseFee: number;
breakdown: BreakdownEntry[];
billTotal: number;
};
discount: number;
subscriptionStatus?: string;
}
const getUsage = async (
licenseKey: string,
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getUsage;

View File

@@ -19,10 +19,8 @@ import type {
import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
@@ -649,87 +647,3 @@ export const useTestChannelDeprecated = <
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));
};
/**
* This endpoint creates a notification channel
* @summary Create notification channel
*/
export const createNotificationChannel = (
alertmanagertypesPostableNotificationChannelDTO?: BodyType<AlertmanagertypesPostableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateNotificationChannel201>({
url: `/api/v2/notification_channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostableNotificationChannelDTO,
signal,
});
};
export const getCreateNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
> => {
const mutationKey = ['createNotificationChannel'];
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 createNotificationChannel>>,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> }
> = (props) => {
const { data } = props ?? {};
return createNotificationChannel(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createNotificationChannel>>
>;
export type CreateNotificationChannelMutationBody =
| BodyType<AlertmanagertypesPostableNotificationChannelDTO>
| undefined;
export type CreateNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create notification channel
*/
export const useCreateNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
TContext
> => {
return useMutation(getCreateNotificationChannelMutationOptions(options));
};

View File

@@ -37,476 +37,6 @@ export interface AlertmanagertypesChannelDTO {
updatedAt?: string;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type string
*/
apiUrl: string;
/**
* @type string
*/
channel?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
/**
* @enum slack
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind;
spec: AlertmanagertypesChannelSlackConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
email = 'email',
}
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
[key: string]: string;
};
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
* @type object
*/
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
/**
* @type string
*/
html?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
to: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO {
/**
* @enum email
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind;
spec: AlertmanagertypesChannelEmailConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind {
webhook = 'webhook',
}
export interface AlertmanagertypesChannelWebhookConfigDTO {
/**
* @type string
*/
bearerToken?: string;
/**
* @type string
*/
password?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
url: string;
/**
* @type string
*/
username?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO {
/**
* @enum webhook
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind;
spec: AlertmanagertypesChannelWebhookConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
pagerduty = 'pagerduty',
}
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
[key: string]: string;
};
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @type string
*/
class?: string;
/**
* @type string
*/
client?: string;
/**
* @type string
*/
clientUrl?: string;
/**
* @type string
*/
component?: string;
/**
* @type string
*/
description?: string;
/**
* @type object
*/
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
/**
* @type string
*/
group?: string;
/**
* @type string
*/
routingKey: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
severity?: string;
/**
* @type string
*/
source?: string;
/**
* @type string
*/
url?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO {
/**
* @enum pagerduty
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind;
spec: AlertmanagertypesChannelPagerdutyConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
opsgenie = 'opsgenie',
}
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
[key: string]: string;
};
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @type string
*/
apiKey: string;
/**
* @type string
*/
apiUrl?: string;
/**
* @type string
*/
description?: string;
/**
* @type object
*/
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
source?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO {
/**
* @enum opsgenie
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind;
spec: AlertmanagertypesChannelOpsgenieConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind {
msteams = 'msteams',
}
export interface AlertmanagertypesChannelMSTeamsConfigDTO {
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
webhookUrl: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO {
/**
* @enum msteams
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind;
spec: AlertmanagertypesChannelMSTeamsConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind {
googlechat = 'googlechat',
}
export interface AlertmanagertypesChannelGoogleChatConfigDTO {
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
text?: string;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
webhookUrl: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO {
/**
* @enum googlechat
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind;
spec: AlertmanagertypesChannelGoogleChatConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
[key: string]: unknown;
};
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
* @type string
*/
apiToken: string;
/**
* @type object
*/
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
email: string;
/**
* @type string
*/
issueType: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project: string;
/**
* @type string
*/
reopenDuration?: string;
/**
* @type string
*/
reopenTransition?: string;
/**
* @type string
*/
resolveTransition?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
site: string;
/**
* @type string
*/
summary?: string;
/**
* @type string
*/
wontFixResolution?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO {
/**
* @enum jira
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind;
spec: AlertmanagertypesChannelJiraConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind {
jsmops = 'jsmops',
}
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @type string
*/
apiKey: string;
/**
* @type string
*/
description?: string;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
tags?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO {
/**
* @enum jsmops
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind;
spec: AlertmanagertypesChannelJSMOpsConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
incidentio = 'incidentio',
}
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
[key: string]: string;
};
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @type string
*/
description?: string;
/**
* @type object
*/
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
token: string;
/**
* @type string
*/
url: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO {
/**
* @enum incidentio
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind;
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
}
export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelKindDTO {
slack = 'slack',
email = 'email',
webhook = 'webhook',
pagerduty = 'pagerduty',
opsgenie = 'opsgenie',
msteams = 'msteams',
googlechat = 'googlechat',
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -558,32 +88,6 @@ export enum AlertmanagertypesExpressionKindDTO {
rule = 'rule',
policy = 'policy',
}
export interface AlertmanagertypesGettableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesGettableRoutePolicyDTO {
/**
* @type array,null
@@ -2244,22 +1748,6 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
wechat_configs?: ConfigWechatConfigDTO[];
};
export interface AlertmanagertypesPostableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
/**
* @type string
*/
displayName?: string;
/**
* @type boolean
*/
generateName?: boolean;
/**
* @type string
*/
name?: string;
}
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
/**
* @type array,null
@@ -13162,14 +12650,6 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -0,0 +1,25 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await axios.get(`/orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -0,0 +1,13 @@
import axios from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const updateCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/checkout', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateCreditCardApi;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
PayloadProps,
} from 'types/api/billing/checkout';
const manageCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/portal', {
url: props.url,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default manageCreditCardApi;

View File

@@ -2,10 +2,8 @@ import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
RawData,
ScalarData,
@@ -13,11 +11,6 @@ import {
} from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
const isBuilderQueryEnvelope = (
envelope: QueryEnvelope,
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
@@ -416,19 +409,21 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
(acc, query) => {
if (
isBuilderQueryEnvelope(query) &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {
if (
query.type === 'builder_query' &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).

View File

@@ -14,7 +14,6 @@ import {
QueryBuilderFormula as V5QueryBuilderFormula,
QueryEnvelope,
QueryRangePayloadV5,
RequestType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -936,41 +935,3 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
});
});
});
describe('convertBuilderQueriesToV5 builder query type', () => {
const buildEnvelope = (
builderQueryType: IBuilderQuery['builderQueryType'],
requestType: RequestType,
): QueryEnvelope => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.TRACES,
queryName: 'A',
builderQueryType,
} as unknown as IBuilderQuery,
},
requestType,
);
return envelope;
};
it.each<[RequestType]>([
['trace'],
['raw'],
['time_series'],
['scalar'],
['distribution'],
])('sends builder_ai_query for the %s request type', (requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_ai_query',
);
});
it.each<[string, IBuilderQuery['builderQueryType']]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('sends builder_query for %s', (_label, builderQueryType) => {
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
});
});

View File

@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
}
return {
type: queryData.builderQueryType ?? 'builder_query',
type: 'builder_query' as QueryType,
spec,
};
},

View File

@@ -4,12 +4,11 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import updateCreditCardApi from 'api/v1/checkout/create';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -19,7 +18,9 @@ export default function ChatSupportGateway(): JSX.Element {
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -37,7 +38,7 @@ export default function ChatSupportGateway(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
updateCreditCardApi,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -93,23 +94,18 @@ export default function ChatSupportGateway(): JSX.Element {
>
Cancel
</Button>,
<AuthZTooltip
<Button
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
Add Credit Card
</Button>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -4,17 +4,16 @@ import { useLocation } from 'react-router-dom';
import { Button, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import updateCreditCardApi from 'api/v1/checkout/create';
import cx from 'classnames';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { defaultTo } from 'lodash-es';
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
@@ -119,7 +118,9 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -137,7 +138,7 @@ function LaunchChatSupport({
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
updateCreditCardApi,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -192,23 +193,18 @@ function LaunchChatSupport({
>
Cancel
</Button>,
<AuthZTooltip
<Button
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
>
Add Credit Card
</Button>
</AuthZTooltip>,
Add Credit Card
</Button>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -16,6 +16,8 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -52,12 +54,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -265,8 +261,10 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -319,9 +317,8 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
const response = await getKeySuggestions({
signal: dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -363,7 +360,6 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -497,11 +493,10 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
: await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -606,7 +601,6 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -1,215 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -1,111 +0,0 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
const { dataSource, builderQueryType } = query;
const { dataSource } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,9 +94,8 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
() => dataSource === DataSource.TRACES,
[dataSource],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -34,7 +34,6 @@ export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
boolValues?: (boolean | null)[];
}): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
@@ -47,7 +46,6 @@ export function mockFieldsValuesAPI(response: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
boolValues: response.boolValues ?? [],
},
},
}),

View File

@@ -22,10 +22,7 @@ interface UseFieldValuesReturn {
isFetching: boolean;
}
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
@@ -92,12 +89,8 @@ export function useFieldValues({
values.numberValues
?.filter((value): value is number => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
const boolValues =
values.boolValues
?.filter((value): value is boolean => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues, ...boolValues];
return [...stringValues, ...numberValues];
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };

View File

@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
function SortableFilter({
filter,
@@ -25,13 +25,13 @@ function SortableFilter({
allowDrag,
allowRemove,
}: {
filter: TelemetryFieldKey;
onRemove: (filter: TelemetryFieldKey) => void;
filter: FilterType;
onRemove: (filter: FilterType) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filter.key as string });
useSortable({ id: filter.key });
const style = {
transform: CSS.Transform.toString(transform),
@@ -46,14 +46,14 @@ function SortableFilter({
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.name}
{filter.key}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter);
onRemove(filter as FilterType);
}}
>
Remove
@@ -69,8 +69,8 @@ function AddedFilters({
setAddedFilters,
}: {
inputValue: string;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
@@ -90,12 +90,12 @@ function AddedFilters({
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
const handleRemoveFilter = (filter: FilterType): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
@@ -116,7 +116,7 @@ function AddedFilters({
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key as string)}
items={addedFilters.map((f) => f.key)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>

View File

@@ -1,17 +1,17 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import {
FieldContext,
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -37,48 +37,106 @@ function OtherFilters({
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const isMeterDataSource = useMemo(
() => signal && signal === SignalType.METER_EXPLORER,
[signal],
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
}));
const addedKeys = new Set(
addedFilters.map((filter) =>
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
),
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isLogDataSource,
},
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
},
);
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: 'meter',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isMeterDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else if (isMeterDataSource) {
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
fieldKeysData?.data?.data?.keys || {},
)?.flat();
filterAttributes = fieldKeys.map(
(attr) =>
({
key: attr.name,
dataType: attr.fieldDataType,
type: attr.fieldContext,
signal: attr.signal,
}) as BaseAutocompleteData,
);
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [
suggestionsData,
aggregateKeysData,
addedFilters,
isLogDataSource,
fieldKeysData,
isMeterDataSource,
]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
type: filter.type,
},
]);
};
const renderFilters = (): React.ReactNode => {
if (isFetching) {
const isLoading =
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
if (isLoading) {
return <OtherFiltersSkeleton />;
}
if (!otherFilters?.length) {
@@ -87,11 +145,11 @@ function OtherFilters({
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.name}</div>
<div className="qf-filter-key">{filter.key}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter)}
onClick={(): void => handleAddFilter(filter as FilterType)}
>
Add
</Button>

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
@@ -18,7 +19,7 @@ function QuickFiltersSettings({
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
customFilters: FilterType[];
refetchCustomFilters: () => void;
}): JSX.Element {
const {
@@ -27,7 +28,6 @@ function QuickFiltersSettings({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
@@ -39,6 +39,18 @@ function QuickFiltersSettings({
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">

View File

@@ -1,31 +1,27 @@
import { useCallback, useMemo, useState } from 'react';
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: TelemetryFieldKey[];
customFilters: FilterType[];
refetchCustomFilters: () => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
hasUnsavedChanges: boolean;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
@@ -41,43 +37,27 @@ const useQuickFilterSettings = ({
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
() =>
customFilters.map((filter) => ({
...filter,
key: buildCompositeKey(
filter.name,
filter.fieldContext,
filter.fieldDataType,
),
})),
[customFilters],
);
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
normalizedCustomFilters,
);
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const { notifications } = useNotifications();
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
useUpdateQuickFilters({
mutation: {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
void logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error) => {
notifications.error({
message: error.message || SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
});
const debouncedUpdate = useDebouncedFn((value) => {
@@ -98,32 +78,19 @@ const useQuickFilterSettings = ({
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(normalizedCustomFilters);
}, [normalizedCustomFilters, setAddedFilters]);
const hasUnsavedChanges = useMemo(
() =>
!(
addedFilters.length === normalizedCustomFilters.length &&
addedFilters.every(
(filter, index) => filter.key === normalizedCustomFilters[index].key,
)
),
[addedFilters, normalizedCustomFilters],
);
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
pathParams: { source: signal },
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
key: filter.key,
datatype: filter.dataType,
type: filter.type,
})),
signal,
},
});
}
@@ -135,7 +102,6 @@ const useQuickFilterSettings = ({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { useQuery } from 'react-query';
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -11,7 +13,7 @@ interface UseFilterConfigProps {
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: TelemetryFieldKey[];
customFilters: FilterType[];
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
@@ -23,16 +25,17 @@ const useFilterConfig = ({
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data,
data: customFilters = [],
refetch,
} = useGetQuickFilters(
{ source: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
[data],
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
);
const isDynamicFilters = useMemo(

View File

@@ -11,7 +11,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import '@testing-library/jest-dom';
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -338,63 +338,6 @@ describe('Quick Filters with custom filters', () => {
);
});
it('keeps same-name fields with different context as distinct entries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
level: [
{
name: 'level',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'level',
fieldContext: 'span',
fieldDataType: 'string',
signal: 'logs',
},
],
},
},
}),
),
),
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
// Both `level` variants are shown despite sharing a name.
await waitFor(() =>
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
);
// Adding one variant removes only that one; the other stays.
const firstLevel = within(otherSection).getAllByText('level')[0];
const addButton = firstLevel.parentElement?.querySelector('button');
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
});
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -513,10 +456,12 @@ describe('Quick Filters with custom filters', () => {
});
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).not.toContainEqual(
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
expect(requestBody.filters).toStrictEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
);
expect(requestBody.filters).toHaveLength(10);
expect(requestBody.signal).toBe(SIGNAL);
});
it('should render duration slider for duration_nono filter', async () => {
@@ -667,9 +612,9 @@ describe('Quick Filters refetch behavior', () => {
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
name: 'new.custom.filter',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
} as const,
],
},

View File

@@ -1,33 +1,17 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
const FILTER_TITLE_MAP: Record<string, string> = {
duration_nano: 'Duration',
hasError: 'Has Error (Status)',
has_error: 'Has Error (Status)',
};
const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
// The map below exists only for the old v3 attribute-values fetch
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
// Once the values fetch moves to fields/values, remove this and reduce
// attributeKey to { id, key }.
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
[TelemetrytypesFieldContextDTO.resource]: 'resource',
};
const mapFieldContext = (fieldContext?: string): string =>
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
@@ -42,16 +26,16 @@ const getFilterName = (str: string): string => {
.join(' ');
};
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
if (FILTER_TYPE_MAP[att.name]) {
return FILTER_TYPE_MAP[att.name];
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: TelemetryFieldKey[],
customFilters?: FilterType[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
@@ -62,13 +46,13 @@ export const getFilterConfig = (
(att, index) =>
({
type: getFilterType(att),
title: getFilterName(att.name),
title: getFilterName(att.key),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.name,
key: att.name,
dataType: fieldDataTypeToDataType(att.fieldDataType),
type: mapFieldContext(att.fieldContext),
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
},
defaultOpen: index < 2,
}) as IQuickFiltersConfig,

View File

@@ -4,18 +4,14 @@ import { refreshLicense } from 'api/generated/services/licenses';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { RefreshCcw } from '@signozhq/icons';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildLicenseUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
import { useAppContext } from 'providers/App/App';
function RefreshPaymentStatus({
type,
className,
withPortal,
}: {
type?: 'button' | 'text' | 'tooltip';
className?: string;
withPortal?: false;
}): JSX.Element {
const { t } = useTranslation(['failedPayment']);
const { activeLicense, activeLicenseRefetch } = useAppContext();
@@ -40,25 +36,17 @@ function RefreshPaymentStatus({
};
const button = (
<AuthZTooltip
checks={
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
}
enabled={!!activeLicense}
withPortal={withPortal}
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
<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>
</AuthZTooltip>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
);
return (
@@ -74,7 +62,6 @@ function RefreshPaymentStatus({
RefreshPaymentStatus.defaultProps = {
type: 'button',
className: undefined,
withPortal: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -348,19 +348,6 @@ export const initialQueryMeterWithType: Query = {
},
};
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
builder: {
...initialQueryWithType.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};
export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators),
number: Object.values(NumberOperators),

View File

@@ -15,6 +15,7 @@ export const REACT_QUERY_KEY = {
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS',
DASHBOARD_BY_ID: 'DASHBOARD_BY_ID',
GET_BILLING_USAGE: 'GET_BILLING_USAGE',
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',

View File

@@ -184,11 +184,6 @@
text-decoration-thickness: 2px;
text-underline-offset: 2px;
}
&[disabled] {
opacity: 0.6;
text-decoration: none;
}
}
.workspace-restricted-banner,

View File

@@ -20,8 +20,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import { updateSubscription } from 'api/generated/services/subscriptions';
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
import manageCreditCardApi from 'api/v1/portal/create';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import getUserVersion from 'api/v1/version/get';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
@@ -31,8 +30,6 @@ import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
@@ -66,7 +63,8 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import {
ChangelogSchema,
DeploymentType,
@@ -79,6 +77,7 @@ import {
} from 'types/api/licensesV3/getActive';
import { UserPreference } from 'types/api/preferences/preference';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
import { showErrorNotification } from 'utils/error';
import { eventEmitter } from 'utils/getEventEmitter';
@@ -167,7 +166,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
return Math.abs(currentDate.diff(userCreationDate, 'day'));
}, [user.createdAt]);
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -185,7 +186,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
};
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(updateSubscription, {
useMutation(manageCreditCardApi, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -468,8 +469,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
history.push(ROUTES.BILLING);
}, []);
if (user.role === USER_ROLES.ADMIN) {
history.push(ROUTES.BILLING);
}
}, [user.role]);
const handleFailedPayment = useCallback((): void => {
manageCreditCard({
@@ -583,21 +586,25 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div>
Our systems are taking longer than expected for your trial workspace.
Please{' '}
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
{user.role === USER_ROLES.ADMIN ? (
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
logEvent(`Slow API Banner: Upgrade clicked`, {});
logEvent(`Slow API Banner: Upgrade clicked`, {});
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
) : (
'contact your administrator for upgrading to a paid plan for a smoother experience.'
)}
</div>
),
duration: 60000,
@@ -787,18 +794,22 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div className="trial-expiry-banner">
You are in free trial period. Your free trial will end on{' '}
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
</span>
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</div>
)}
@@ -815,20 +826,22 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
.
</span>
<span>
{' '}
Please{' '}
<AuthZTooltip checks={SubscriptionManagePermissions}>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
</AuthZTooltip>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</span>
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</div>
)}
</div>

View File

@@ -1,113 +0,0 @@
import {
SubscriptionCreatePermission,
SubscriptionReadPermission,
SubscriptionUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { trialConvertedToSubscriptionResponse } from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import BillingContainer from './BillingContainer';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('BillingContainer - AuthZ', () => {
afterEach(() => {
server.resetHandlers();
});
it('renders usage and enables actions when all subscription permissions are granted', async () => {
server.use(setupAuthzAdmin());
render(<BillingContainer />);
await expect(
screen.findByRole('columnheader', { name: /data ingested/i }),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeEnabled();
});
expect(screen.queryByText(/not authorized/i)).not.toBeInTheDocument();
});
it('blocks the usage section when subscription read is denied', async () => {
server.use(setupAuthzDeny(SubscriptionReadPermission));
render(<BillingContainer />);
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('header-billing-button')).toBeInTheDocument();
expect(
screen.queryByRole('columnheader', { name: /data ingested/i }),
).not.toBeInTheDocument();
});
it('disables upgrade when subscription create is denied', async () => {
server.use(setupAuthzAllow(SubscriptionReadPermission));
render(<BillingContainer />);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.getByTestId('upgrade-plan-button')).toBeDisabled();
});
it('disables manage billing when subscription update is denied', async () => {
server.use(
setupAuthzAllow(SubscriptionReadPermission, SubscriptionCreatePermission),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
expect(screen.queryByTestId('upgrade-plan-button')).not.toBeInTheDocument();
});
it('disables manage billing when subscription list is denied', async () => {
server.use(
setupAuthzAllow(
SubscriptionReadPermission,
SubscriptionCreatePermission,
SubscriptionUpdatePermission,
),
);
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
});
});
});

View File

@@ -4,7 +4,7 @@
margin: 0 auto var(--spacing-20);
.pageHeader {
margin-bottom: var(--spacing-4);
margin-bottom: var(--spacing-8);
.pageHeaderTitle {
font-weight: var(--label-medium-500-font-weight);
@@ -41,8 +41,6 @@
}
.pageInfo {
margin-bottom: var(--spacing-4);
:global(.ant-card) {
padding: var(--padding-3);
}
@@ -60,12 +58,8 @@
margin: var(--spacing-12) var(--spacing-4);
}
.usageDenied {
margin-bottom: var(--spacing-4);
}
.billingDetails {
margin: var(--spacing-4) 0;
margin: var(--spacing-12) 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
overflow: hidden;
@@ -134,7 +128,7 @@
}
.upgradePlanBenefits {
margin: 0;
margin: 0 var(--spacing-4);
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 var(--padding-12);
@@ -182,7 +176,7 @@
}
.billingUpdateNote {
margin-top: var(--spacing-4);
margin-top: var(--spacing-8);
font-family: var(--font-family-inter);
font-size: var(--font-size-sm);
font-style: normal;

View File

@@ -1,11 +1,9 @@
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { billingSuccessResponse } from 'mocks-server/__mockdata__/billing';
import {
licensesSuccessResponse,
notOfTrailResponse,
trialConvertedToSubscriptionResponse,
} from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
import APIError from 'types/api/error';
import {
@@ -17,6 +15,11 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
__esModule: true,
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -28,22 +31,14 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
it('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = await screen.findByRole('columnheader', {
const dataInjection = screen.getByRole('columnheader', {
name: /data ingested/i,
});
expect(dataInjection).toBeInTheDocument();
const pricePerUnit = await screen.findByRole('columnheader', {
const pricePerUnit = screen.getByRole('columnheader', {
name: /price per unit/i,
});
expect(pricePerUnit).toBeInTheDocument();
@@ -54,15 +49,13 @@ describe('BillingContainer', () => {
const dayRemainingInBillingPeriod = await screen.findByText(
/Please upgrade plan now to retain your data./i,
{},
{ timeout: 5000 },
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
const upgradePlanButton = screen.getByTestId('upgrade-plan-button');
expect(upgradePlanButton).toBeInTheDocument();
const dollar = await screen.findByText(/\$1,278.3/i, {}, { timeout: 5000 });
const dollar = await screen.findByText(/\$1,278.3/i);
expect(dollar).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
@@ -93,9 +86,7 @@ describe('BillingContainer', () => {
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
await expect(
screen.findByText(/\$0/i, {}, { timeout: 5000 }),
).resolves.toBeInTheDocument();
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
await expect(
screen.findByText(
@@ -141,7 +132,7 @@ describe('BillingContainer', () => {
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i, {}, { timeout: 5000 });
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
@@ -259,11 +250,7 @@ describe('BillingContainer', () => {
billingSuccessResponse.data.billingPeriodStart,
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
const billingPeriod = await findByText(
billingPeriodText,
{},
{ timeout: 5000 },
);
const billingPeriod = await findByText(billingPeriodText);
expect(billingPeriod).toBeInTheDocument();
const currentBill = await screen.findByText('billing');

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import {
Card,
@@ -15,36 +15,25 @@ import {
TableColumnsType as ColumnsType,
} from 'antd';
import { Badge } from '@signozhq/ui/badge';
import getUsage, {
BreakdownEntry,
UsageResponsePayloadProps,
} from 'api/billing/getUsage';
import logEvent from 'api/common/logEvent';
import type {
CreateSubscription201,
GetSubscription200,
SubscriptiontypesGettableSubscriptionUsageDTO,
SubscriptiontypesSubscriptionUsageBreakdownDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
createSubscription,
updateSubscription,
useGetSubscription,
} from 'api/generated/services/subscriptions';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useAxiosError from 'hooks/useAxiosError';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty, pick } from 'lodash-es';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import {
SubscriptionCreatePermission,
SubscriptionManagePermissions,
SubscriptionReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
import { useAppContext } from 'providers/App/App';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
@@ -146,7 +135,7 @@ export default function BillingContainer(): JSX.Element {
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<DataType[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
Partial<UsageResponsePayloadProps>
>({});
const {
@@ -157,8 +146,7 @@ export default function BillingContainer(): JSX.Element {
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
useAuthZ([SubscriptionReadPermission]);
const { licenseKey } = useActiveLicenseKey();
const { notifications } = useNotifications();
const handleError = useAxiosError();
@@ -166,34 +154,33 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(response: GetSubscription200): void => {
const usage = response?.data;
if (isEmpty(usage)) {
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
if (isEmpty(data?.payload)) {
return;
}
const breakdown = usage.details?.breakdown ?? [];
const billTotal = usage.details?.billTotal ?? 0;
const billingPeriodStart = usage.billingPeriodStart ?? 0;
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
const {
details: { breakdown = [], billTotal },
billingPeriodStart,
billingPeriodEnd,
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
const formattedUsageData: DataType[] = [];
breakdown.forEach(
(
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
index: number,
) => {
element?.tiers?.forEach((tier, tierIndex: number) => {
if (breakdown && Array.isArray(breakdown)) {
for (let index = 0; index < breakdown.length; index += 1) {
const element: BreakdownEntry = breakdown[index];
element?.tiers?.forEach((tier, i: number) => {
formattedUsageData.push({
key: `${index}${tierIndex}`,
name: tierIndex === 0 ? (element?.type ?? '') : '',
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
unit: element?.unit ?? '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: String(tier.unitPrice),
cost: `$ ${tier.tierCost}`,
});
});
},
);
}
}
setData(formattedUsageData);
@@ -209,7 +196,7 @@ export default function BillingContainer(): JSX.Element {
setBillAmount(billTotal);
}
setApiResponse(usage);
setApiResponse(data?.payload || {});
},
[trialInfo?.onTrial],
);
@@ -221,12 +208,11 @@ export default function BillingContainer(): JSX.Element {
isLoading,
isFetching: isFetchingBillingData,
data: billingData,
} = useGetSubscription({
query: {
enabled: canReadSubscription || !!subscriptionAuthZError,
onError: handleError,
onSuccess: processUsageData,
},
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
queryFn: () => getUsage(licenseKey || ''),
onError: handleError,
enabled: !!licenseKey,
onSuccess: processUsageData,
});
useEffect(() => {
@@ -298,7 +284,9 @@ export default function BillingContainer(): JSX.Element {
/>
);
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -315,7 +303,7 @@ export default function BillingContainer(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
updateCreditCardApi,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -325,7 +313,7 @@ export default function BillingContainer(): JSX.Element {
);
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(updateSubscription, {
useMutation(manageCreditCardApi, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -360,21 +348,15 @@ export default function BillingContainer(): JSX.Element {
updateCreditCard,
]);
const billingActionPermissions = trialInfo?.trialConvertedToSubscription
? SubscriptionManagePermissions
: [SubscriptionCreatePermission];
const subscriptionPastDueMessage = (): JSX.Element => (
<Typography>
{`We were not able to process payments for your account. Please update your card details `}
<AuthZTooltip checks={billingActionPermissions}>
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
</AuthZTooltip>
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
{` if your payment information has changed. Email us at `}
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
@@ -429,7 +411,11 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Flex>
<Card bordered={false} className={styles.pageInfo}>
<Card
bordered={false}
style={{ minHeight: 150, marginBottom: 16 }}
className={styles.pageInfo}
>
<Flex justify="space-between" align="center">
<Flex vertical gap={8}>
<p className={styles.pageInfoTitle}>
@@ -437,14 +423,13 @@ export default function BillingContainer(): JSX.Element {
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</p>
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
<p className={styles.pageInfoSubtitle}>
{daysRemaining} {daysRemainingStr}
</p>
) : null}
</Flex>
<AuthZButton
checks={billingActionPermissions}
<Button
testId="header-billing-button"
variant="solid"
color="secondary"
@@ -458,7 +443,7 @@ export default function BillingContainer(): JSX.Element {
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</AuthZButton>
</Button>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -510,81 +495,66 @@ export default function BillingContainer(): JSX.Element {
))}
</Card>
<AuthZGuardContent
checks={[SubscriptionReadPermission]}
fallback={({ deniedPermissions }): JSX.Element => (
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
className={styles.usageDenied}
<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>
)}
<div className={styles.billingDetails}>
{!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}`}
/>
);
},
},
}}
/>
)}
>
<>
<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>
)}
<div className={styles.billingDetails}>
{!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}`}
/>
);
},
},
}}
/>
)}
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
</>
</AuthZGuardContent>
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
@@ -627,8 +597,7 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<AuthZButton
checks={[SubscriptionCreatePermission]}
<Button
testId="upgrade-plan-button"
variant="solid"
color="primary"
@@ -637,7 +606,7 @@ export default function BillingContainer(): JSX.Element {
onClick={handleBilling}
>
{t('upgrade_plan')}
</AuthZButton>
</Button>
</Col>
</Row>
</div>

View File

@@ -12,7 +12,7 @@ import {
} from 'lib/uPlotV2/components/types';
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import type uPlot from 'uplot';
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
@@ -25,7 +25,7 @@ import {
import styles from './BillingUsageGraph.module.scss';
interface BillingUsageGraphProps {
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
data: Partial<UsageResponsePayloadProps>;
billAmount: number;
}
@@ -55,7 +55,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
const nextDay = {
...currentDay,
timestamp: (currentDay.timestamp ?? 0) + 86400,
timestamp: currentDay.timestamp + 86400,
count: 0,
size: 0,
quantity: 0,
@@ -94,9 +94,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
),
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
[normalizedData],
);

View File

@@ -1,4 +1,4 @@
import { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -117,9 +117,7 @@ export function csvFileName(csvData: QuantityData[]): string {
return `billing_usage_(${startDate}-${endDate}).csv`;
}
export function prepareCsvData(
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
): {
export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
csvData: string;
fileName: string;
} {
@@ -137,14 +135,12 @@ export function prepareCsvData(
}
export function calculateStartEndTime(
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
data: Partial<UsageResponsePayloadProps>,
): { startTime: number | undefined; endTime: number | undefined } {
const timestamps: number[] = [];
data?.details?.breakdown?.forEach((breakdown) => {
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
if (typeof entry.timestamp === 'number') {
timestamps.push(entry.timestamp);
}
timestamps.push(entry.timestamp);
});
});

View File

@@ -6,7 +6,7 @@
border-radius: 4px;
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
margin: var(--spacing-4) 0;
margin: var(--spacing-4) 0 var(--spacing-12);
}
.info {

View File

@@ -1,9 +1,3 @@
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
setupAuthzAdmin,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -42,24 +36,10 @@ function mockMailto(): {
}
describe('CancelSubscriptionBanner', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
jest.restoreAllMocks();
});
it('disables Cancel Subscription when subscription delete is denied', async () => {
server.use(setupAuthzDeny(SubscriptionDeletePermission));
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeDisabled();
});
});
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
@@ -76,10 +56,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
@@ -97,10 +76,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
@@ -117,10 +95,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
@@ -130,10 +107,9 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
@@ -143,10 +119,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -176,10 +151,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -198,10 +172,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -219,10 +192,9 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));

View File

@@ -11,8 +11,6 @@ import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
@@ -180,17 +178,15 @@ function CancelSubscriptionBanner(): JSX.Element {
immediately and removed from our servers.
</span>
</div>
<AuthZButton
checks={[SubscriptionDeletePermission]}
<Button
variant="solid"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
testId="cancel-subscription-btn"
>
Cancel Subscription
</AuthZButton>
</Button>
</div>
<DialogWrapper
open={dialogView !== null}

View File

@@ -1,14 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: 8px 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}

View File

@@ -1,82 +0,0 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
return (
<div className={styles.container}>
{config?.fieldsSelector && (
<>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
)}
<Controls
isLoading={isLoading}
totalCount={totalCount}
offset={pagination.offset}
countPerPage={pagination.limit}
perPageOptions={perPageOptions}
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

@@ -11,13 +11,17 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import {
getExportQueryData,
getQueryByPanelType,
} from 'container/TracesExplorer/explorerUtils';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
@@ -48,7 +52,6 @@ import {
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
@@ -115,7 +118,7 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
initialQueriesMap.traces,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
@@ -182,7 +185,7 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
stagedQuery || initialQueriesMap.traces,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],

View File

@@ -17,11 +17,12 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import {
getTraceLink,
transformSpanRows,
@@ -42,7 +43,6 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
PER_PAGE_OPTIONS,
@@ -94,7 +94,7 @@ function ListView({
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);

View File

@@ -1,168 +0,0 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return '';
}
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => {
const row = item.data as Record<string, unknown>;
return {
...row,
timestamp: item.timestamp,
id: row.span_id,
};
}) as TracesTableRow[];
};

View File

@@ -1,6 +1,8 @@
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
@@ -8,16 +10,33 @@ import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
@@ -26,10 +45,14 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={isListViewPanel}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -1,19 +0,0 @@
.loading-traces {
padding: 24px 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
.loading-traces-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}

View File

@@ -1,22 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import './TraceLoading.styles.scss';
export function TracesLoading(): JSX.Element {
const { t } = useTranslation('common');
return (
<div className="loading-traces">
<div className="loading-traces-content">
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
<Typography>
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
</Typography>
</div>
</div>
);
}

View File

@@ -1,77 +0,0 @@
import { generatePath, Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {
DURATION_FIELD_NAMES,
STATUS_FIELD_NAMES,
TIMESTAMP_FIELD_NAMES,
TRACE_ID_FIELD_NAMES,
} from './constants';
import { stringifyCellValue } from './utils';
type FieldCellProps = {
name: string;
value: unknown;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
const text = String(formatted);
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>
{text}
</Link>
);
}
if (STATUS_FIELD_NAMES.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(name)) {
return (
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
);
}
return (
<TanStackTable.Text data-testid={name} title={text}>
{text}
</TanStackTable.Text>
);
}
export default FieldCell;

View File

@@ -1,26 +0,0 @@
.tableWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
}

View File

@@ -1,116 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import type { TracesTableRow } from './getFieldColumn';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
data: TracesTableRow[];
columns: TableColumnDef<TracesTableRow>[];
columnStorageKey?: string;
respectColumnOrder?: boolean;
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: APIError | Error | null;
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
};
function TracesTable({
data,
columns,
columnStorageKey,
respectColumnOrder = false,
panelType,
getRowHref,
isLoading,
isFetching,
isError,
error,
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const history = useHistory();
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
},
[history, getRowHref],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
);
return (
<>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}
columns={columns}
className={styles.tracesTable}
columnStorageKey={columnStorageKey}
respectColumnOrder={respectColumnOrder}
isLoading={isFetching}
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
/>
</div>
)}
</>
);
}
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
};
export default TracesTable;

View File

@@ -1,18 +0,0 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -1,26 +0,0 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext, fieldDataType } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
};
}

View File

@@ -1,12 +0,0 @@
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -14,9 +14,10 @@ import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
@@ -30,7 +31,6 @@ import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { getListViewQuery } from '../explorerUtils';
import { columns, PER_PAGE_OPTIONS } from './configs';
import styles from './TracesView.module.scss';
@@ -60,7 +60,7 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
[stagedQuery],
);

View File

@@ -1,6 +1,9 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,235 +0,0 @@
/**
* AI Assistant page-action factories for the Traces Explorer.
*
* Mirrors the logs equivalents — each factory closes over live page
* state/callbacks so `execute()` always operates on the current query, and
* the page component instantiates them via `useMemo` + `usePageActions`.
*
* See `pages/LogsExplorer/aiActions.ts` for the rationale behind writing
* BOTH `filters.items` and `filter.expression` and then re-using the same
* URL parser shape via `redirectWithQueryBuilderData`.
*/
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import {
aiFilterToTagFilterItem,
FILTER_OP_ENUM,
FILTER_VALUE_DESCRIPTION,
FilterDeps,
replaceFirstQueryData,
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
import {
ActionResult,
PageAction,
} from 'container/AIAssistant/pageActions/types';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
interface AIFilter {
key: string;
op: string;
value: string;
}
interface RunQueryParams {
filters: AIFilter[];
}
interface AddFilterParams {
key: string;
op: string;
value: string;
}
type TracesView = 'list' | 'timeseries' | 'table' | 'trace';
interface ChangeViewParams {
view: TracesView;
}
interface SaveViewParams {
name: string;
}
/**
* Replace all active span filters and navigate to the updated query URL
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
*/
export function tracesRunQueryAction(
deps: FilterDeps,
): PageAction<RunQueryParams> {
return {
id: 'traces.runQuery',
description: 'Replace the active trace filters and re-run the query',
parameters: {
type: 'object',
properties: {
filters: {
type: 'array',
description: 'Replacement filter list',
items: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
},
},
required: ['filters'],
},
autoApply: true,
execute: async ({ filters }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const tagItems = filters.map(aiFilterToTagFilterItem);
const newFilters = { items: tagItems, op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return {
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
};
},
getContext: (): Record<string, unknown> => ({
filters:
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
(f: TagFilterItem) => ({
key: f.key?.key,
op: f.op,
value: f.value,
}),
) ?? [],
}),
};
}
/**
* Append a single filter to the existing trace query and navigate to the
* updated URL.
*/
export function tracesAddFilterAction(
deps: FilterDeps,
): PageAction<AddFilterParams> {
return {
id: 'traces.addFilter',
description: 'Add a single filter to the current trace query and re-run',
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
autoApply: true,
execute: async ({ key, op, value }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const existing = baseQuery.filters?.items ?? [];
const newItem = aiFilterToTagFilterItem({ key, op, value });
const newFilters = { items: [...existing, newItem], op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
},
};
}
/**
* Switch the traces explorer between list / timeseries / table / trace views.
*/
export function tracesChangeViewAction(deps: {
onChangeView: (view: TracesView) => void;
}): PageAction<ChangeViewParams> {
return {
id: 'traces.changeView',
description:
'Switch the Traces Explorer between list, timeseries, table, and trace views',
parameters: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['list', 'timeseries', 'table', 'trace'],
description: 'The panel view to switch to',
},
},
required: ['view'],
},
execute: async ({ view }): Promise<ActionResult> => {
deps.onChangeView(view);
return { summary: `Switched to the "${view}" view.` };
},
};
}
/**
* Save the current trace query as a named view (stub — wires to real API
* when available).
*/
export function tracesSaveViewAction(deps: {
onSaveView: (name: string) => Promise<void>;
}): PageAction<SaveViewParams> {
return {
id: 'traces.saveView',
description: 'Save the current trace query as a named view',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name for the saved view' },
},
required: ['name'],
},
execute: async ({ name }): Promise<ActionResult> => {
await deps.onSaveView(name);
return { summary: `View "${name}" saved.` };
},
};
}

View File

@@ -1,61 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
stagedQuery: Query,
orderBy?: string,
): Query => {
const query = stagedQuery
? cloneDeep(stagedQuery)
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
queryData.groupBy = [];
queryData.having = {
expression: '',
};
queryData.orderBy = orderByPayload;
}
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -1,132 +0,0 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import './ToolbarActions.styles.scss';
interface LeftToolbarActionsProps {
items: any;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
handleFilterVisibilityChange: () => void;
}
const activeTab = 'active-tab';
export default function LeftToolbarActions({
items,
selectedView,
onChangeSelectedView,
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size={12} />
<ArrowUpToLine size={12} style={{ transform: 'rotate(90deg)' }} />
</Button>
</Tooltip>
)}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -1,125 +0,0 @@
.left-toolbar {
display: flex;
align-items: center;
.filter-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
height: 32px;
margin-right: 12px;
border: 1px solid var(--l1-border);
}
.left-toolbar-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.prom-ql-icon {
height: 14px;
width: 14px;
}
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
gap: 8px;
&.active-tab {
background-color: var(--primary-background);
border-bottom: 1px solid var(--primary-background);
color: var(--primary-foreground);
&:hover {
background-color: var(--primary-background) !important;
}
}
&:disabled {
background-color: var(--l3-background);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--l1-foreground);
}
}
}
.frequency-chart-view-controller {
display: flex;
align-items: center;
padding-left: 8px;
gap: 8px;
}
}
.right-toolbar {
display: flex;
align-items: center;
background-color: var(--bg-robin-600);
}
.right-actions {
display: flex;
align-items: center;
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
.loading-btn {
display: flex;
width: 32px;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l3-background);
box-shadow: none;
border: none;
}
.cancel-run {
display: flex;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex: 1 0 0;
border-radius: 2px;
background: var(--danger-background);
border: none;
}
.cancel-run:hover {
background-color: var(--bg-cherry-400) !important;
color: var(--l1-foreground) !important;
}
}

View File

@@ -108,6 +108,7 @@ function LogsExplorerViewsContainer({
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
const [queryId, setQueryId] = useState<string>(v4());
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
@@ -179,7 +180,12 @@ function LogsExplorerViewsContainer({
},
undefined,
listQueryKeyRef,
undefined,
{
...(!isEmpty(queryId) &&
selectedPanelType !== PANEL_TYPES.LIST && {
'X-SIGNOZ-QUERY-ID': queryId,
}),
},
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
'custom',
);
@@ -244,6 +250,10 @@ function LogsExplorerViewsContainer({
setRequestData(newRequestData);
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
useEffect(() => {
setQueryId(v4());
}, [data]);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {

View File

@@ -329,17 +329,16 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(9);
expect(result).toHaveLength(8);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'license',
'logs',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});
@@ -420,17 +419,16 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(9);
expect(result).toHaveLength(8);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'license',
'logs',
'meter-metrics',
'metrics',
'role',
'serviceaccount',
'subscription',
'license',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});

View File

@@ -6,7 +6,6 @@ import {
Gauge,
Key,
Logs,
Receipt,
Shield,
} from '@signozhq/icons';
@@ -70,13 +69,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
selectorPlaceholder: 'Type license ID, separate multiple with comma or space',
docsAnchor: 'license',
},
subscription: {
label: 'Subscription',
description: 'The workspace subscription, its usage and billing details.',
icon: Receipt,
selectorPlaceholder: 'Type * to cover the workspace subscription',
docsAnchor: 'subscription',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
@@ -115,11 +107,7 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
},
};
export const RESOURCE_ORDER = (
Object.keys(RESOURCE_PANELS) as AuthZResource[]
).sort((left, right) =>
RESOURCE_PANELS[left].label.localeCompare(RESOURCE_PANELS[right].label),
);
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
const panel = RESOURCE_PANELS[resource];

View File

@@ -1,13 +0,0 @@
import { useAutoRefreshSelection } from './useAutoRefreshSelection';
import { useAutoRefreshTick } from './useAutoRefreshTick';
/** Auto-refresh timer for views that hide the time selector that normally owns it. */
function AutoRefreshTicker(): null {
const { isEnabled, intervalMs } = useAutoRefreshSelection();
useAutoRefreshTick(isEnabled, intervalMs);
return null;
}
export default AutoRefreshTicker;

View File

@@ -1,116 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { act, render, screen } from '@testing-library/react';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { AppState } from 'store/reducers';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../index';
const mockStore = configureStore<Partial<AppState>>([]);
const PATHNAME = '/dashboard/test-id';
const randomTime = 1700000000000000000;
function createGlobalTimeState(
overrides: Partial<GlobalReducer> = {},
): GlobalReducer {
return {
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
...overrides,
};
}
function renderAutoRefresh(
globalTime: GlobalReducer,
props: { disabled?: boolean } = {},
): MockStoreEnhanced<Partial<AppState>> {
const store = mockStore({ globalTime });
render(
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<AutoRefresh {...props} />
</Provider>
</MemoryRouter>,
);
return store;
}
function tickCount(store: MockStoreEnhanced<Partial<AppState>>): number {
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL)
.length;
}
describe('AutoRefresh', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders the trigger and ticks on the persisted interval', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(createGlobalTimeState());
expect(screen.getByTitle('Set auto refresh')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(15_000);
});
expect(tickCount(store)).toBe(3);
});
it('does not tick when auto refresh was never enabled for the route', () => {
const store = renderAutoRefresh(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
it('does not tick while the disabled prop is set', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(createGlobalTimeState(), { disabled: true });
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
it('renders nothing on a custom time range', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderAutoRefresh(
createGlobalTimeState({ selectedTime: 'custom' }),
);
expect(screen.queryByTitle('Set auto refresh')).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(tickCount(store)).toBe(0);
});
});

View File

@@ -1,162 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { act, render } from '@testing-library/react';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
import { AppState } from 'store/reducers';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../index';
import AutoRefreshTicker from '../AutoRefreshTicker';
const mockStore = configureStore<Partial<AppState>>([]);
const PATHNAME = '/dashboard/test-id';
const randomTime = 1700000000000000000;
function createGlobalTimeState(
overrides: Partial<GlobalReducer> = {},
): GlobalReducer {
return {
minTime: randomTime,
maxTime: randomTime,
loading: false,
selectedTime: '15m',
isAutoRefreshDisabled: false,
selectedAutoRefreshInterval: '5s',
...overrides,
};
}
function renderTicker(
globalTime: GlobalReducer,
): MockStoreEnhanced<Partial<AppState>> {
const store = mockStore({ globalTime });
render(
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<AutoRefreshTicker />
</Provider>
</MemoryRouter>,
);
return store;
}
function timeIntervalActions(
store: MockStoreEnhanced<Partial<AppState>>,
): unknown[] {
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL);
}
describe('AutoRefreshTicker', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('advances the global time window on the interval persisted for the route', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(2);
});
it('does not tick when the route has no persisted interval', () => {
const store = renderTicker(createGlobalTimeState());
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
it('does not tick while auto refresh is globally disabled', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(
createGlobalTimeState({ isAutoRefreshDisabled: true }),
);
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
it('does not tick on a custom time range', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = renderTicker(createGlobalTimeState({ selectedTime: 'custom' }));
act(() => {
jest.advanceTimersByTime(60_000);
});
expect(timeIntervalActions(store)).toHaveLength(0);
});
});
// Mirrors DashboardContainer's swap: exactly one of the two must be ticking.
describe('AutoRefresh full screen handover', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
});
it('keeps a single timer running across entering and leaving full screen', () => {
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
const store = mockStore({ globalTime: createGlobalTimeState() });
function Harness({ active }: { active: boolean }): JSX.Element {
return active ? <AutoRefreshTicker /> : <AutoRefresh />;
}
const renderHarness = (active: boolean): JSX.Element => (
<MemoryRouter initialEntries={[PATHNAME]}>
<Provider store={store}>
<Harness active={active} />
</Provider>
</MemoryRouter>
);
const { rerender } = render(renderHarness(false));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(2);
rerender(renderHarness(true));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(4);
rerender(renderHarness(false));
act(() => {
jest.advanceTimersByTime(10_000);
});
expect(timeIntervalActions(store)).toHaveLength(6);
});
});

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useInterval } from 'react-use';
import { Check, ChevronDown } from '@signozhq/icons';
import { Button, Popover } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
@@ -10,18 +11,21 @@ import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import useUrlQuery from 'hooks/useUrlQuery';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import _omit from 'lodash-es/omit';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_AUTO_REFRESH_INTERVAL } from 'types/actions/globalTime';
import {
UPDATE_AUTO_REFRESH_INTERVAL,
UPDATE_TIME_INTERVAL,
} from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { refreshIntervalOptions } from './constants';
import { ButtonContainer } from './styles';
import { useAutoRefreshTick } from './useAutoRefreshTick';
import './AutoRefreshV2.styles.scss';
@@ -89,10 +93,30 @@ function AutoRefresh({
[selectedOption],
);
useAutoRefreshTick(
!isDisabled && isAutoRefreshEnabled && selectedOption !== 'off',
getOption?.value || 0,
);
useInterval(() => {
const selectedValue = getOption?.value;
if (isDisabled || !isAutoRefreshEnabled) {
return;
}
if (selectedOption !== 'off' && selectedValue) {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
}
}, getOption?.value || 0);
const onChangeHandler = useCallback(
(selectedValue: string) => {

View File

@@ -1,29 +0,0 @@
import { useLocation } from 'react-router-dom';
import get from 'api/browser/localstorage/get';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import { refreshIntervalOptions } from './constants';
export interface AutoRefreshSelection {
isEnabled: boolean;
intervalMs: number;
}
/**
* An entry for the current route means auto-refresh is on, its absence means off.
* Read on every render because localStorage isn't reactive.
*/
export function useAutoRefreshSelection(): AutoRefreshSelection {
const { pathname } = useLocation();
const selectedOption = JSON.parse(get(DASHBOARD_TIME_IN_DURATION) || '{}')[
pathname
];
return {
isEnabled: Boolean(selectedOption),
intervalMs:
refreshIntervalOptions.find((option) => option.key === selectedOption)
?.value || 0,
};
}

View File

@@ -1,47 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { useInterval } from 'react-use';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Advances the global time window on the auto-refresh interval. The global
* "auto refresh disabled" flag and a custom range override the caller's `enabled`.
*/
export function useAutoRefreshTick(enabled: boolean, intervalMs: number): void {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const dispatch = useDispatch<Dispatch<AppActions>>();
const isTicking =
enabled &&
intervalMs > 0 &&
!globalTime.isAutoRefreshDisabled &&
globalTime.selectedTime !== 'custom';
useInterval(
() => {
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
},
isTicking ? intervalMs : null,
);
}

View File

@@ -13,11 +13,6 @@ export default {
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'role',
type: 'role',

View File

@@ -4,5 +4,3 @@ import type { BrandedPermission } from '../types';
// Resource-level — require a specific license id
export const buildLicenseReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `license:${id}`);
export const buildLicenseUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `license:${id}`);

View File

@@ -1,26 +0,0 @@
import { buildPermission } from '../utils';
export const SubscriptionReadPermission = buildPermission(
'read',
'subscription:*',
);
export const SubscriptionCreatePermission = buildPermission(
'create',
'subscription:*',
);
export const SubscriptionUpdatePermission = buildPermission(
'update',
'subscription:*',
);
export const SubscriptionListPermission = buildPermission(
'list',
'subscription:*',
);
export const SubscriptionDeletePermission = buildPermission(
'delete',
'subscription:*',
);
export const SubscriptionManagePermissions = [
SubscriptionListPermission,
SubscriptionUpdatePermission,
];

View File

@@ -4,85 +4,114 @@ export const quickFiltersListResponse = {
signal: 'logs',
filters: [
{
name: 'os.description',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'os.description',
dataType: 'string',
type: 'resource',
},
{
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'service.name',
dataType: 'string',
type: 'resource',
},
{
name: 'duration_nano',
fieldDataType: 'float64',
fieldContext: 'attribute',
key: 'duration_nano',
dataType: 'float64',
type: 'tag',
},
{
name: 'quantity',
fieldDataType: 'float64',
fieldContext: 'attribute',
key: 'quantity',
dataType: 'float64',
type: 'tag',
},
{
name: 'body',
fieldDataType: 'string',
fieldContext: '',
key: 'body',
dataType: 'string',
type: '',
},
{
name: 'deployment.environment',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
},
{
name: 'service.namespace',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'service.namespace',
dataType: 'string',
type: 'resource',
},
{
name: 'k8s.namespace.name',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
},
{
name: 'service.instance.id',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
},
{
name: 'k8s.pod.name',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
},
{
name: 'process.owner',
fieldDataType: 'string',
fieldContext: 'resource',
key: 'process.owner',
dataType: 'string',
type: 'resource',
},
],
},
};
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
[name]: [
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
],
});
export const otherFiltersResponse = {
status: 'success',
data: {
complete: true,
keys: {
...otherFilterName('service.name'),
...otherFilterName('k8s.deployment.name'),
...otherFilterName('deployment.environment'),
...otherFilterName('service.namespace'),
...otherFilterName('k8s.namespace.name'),
...otherFilterName('service.instance.id'),
...otherFilterName('k8s.pod.name'),
...otherFilterName('k8s.pod.uid'),
...otherFilterName('os.description'),
},
attributes: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.deployment.name',
dataType: 'string',
type: 'resource',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.uid',
dataType: 'string',
type: 'resource',
},
{
key: 'os.description',
dataType: 'string',
type: 'resource',
},
],
},
};

View File

@@ -139,7 +139,7 @@ export const handlers = [
res(ctx.status(200), ctx.json(licensesSuccessResponse)),
),
rest.get('http://localhost/api/v1/subscriptions', (req, res, ctx) =>
rest.get('http://localhost/api/v1/billing', (req, res, ctx) =>
res(ctx.status(200), ctx.json(billingSuccessResponse)),
),

View File

@@ -153,21 +153,6 @@ function TablePanelRenderer({
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [searchTerm]);
// The measured size is only a default; without this the controlled `pageSize`
// snaps a size-changer pick straight back to the fitted value.
const [selectedPageSize, setSelectedPageSize] = useState<number>();
const effectivePageSize = selectedPageSize ?? pageSize;
const handlePaginationChange = useCallback(
(nextPage: number, nextPageSize: number): void => {
setPage(nextPage);
if (nextPageSize !== effectivePageSize) {
setSelectedPageSize(nextPageSize);
}
},
[effectivePageSize],
);
return (
<div
ref={containerRef}
@@ -185,10 +170,10 @@ function TablePanelRenderer({
dataSource={filteredDataSource}
pagination={{
current: page,
pageSize: effectivePageSize,
pageSize,
hideOnSinglePage: true,
size: 'small',
onChange: handlePaginationChange,
onChange: setPage,
}}
scroll={{ x: 'max-content', y: scrollY }}
/>

View File

@@ -1,4 +1,3 @@
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesTablePanelSpecDTO,
type QueryRangeV5200,
@@ -11,7 +10,6 @@ import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import { MIN_PAGE_SIZE } from '../../../utils/recordTable';
import TablePanelRenderer from '../Renderer';
function panelWith(
@@ -131,27 +129,6 @@ describe('TablePanelRenderer', () => {
expect(queryByText('frontend')).not.toBeInTheDocument();
});
it('keeps a page size picked from the size changer', async () => {
const rows = Array.from({ length: 60 }, (_, index): [string, number] => [
`service-${index}`,
index,
]);
const { container, getByText } = renderPanel({ data: dataWith(rows) });
const countRows = (): number =>
container.querySelectorAll('.ant-table-tbody tr.ant-table-row').length;
expect(countRows()).toBe(MIN_PAGE_SIZE);
const sizeChanger = container.querySelector(
'.ant-pagination-options .ant-select-selector',
) as Element;
await userEvent.click(sizeChanger);
await userEvent.click(getByText('20 / page'));
expect(countRows()).toBe(20);
});
it('keeps the table mounted (not No Data) when the search matches no rows', () => {
const { getByTestId, queryByText } = renderPanel({
data: dataWith([['frontend', 1234]]),

View File

@@ -2,7 +2,6 @@ import { useEffect } from 'react';
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import AutoRefreshTicker from 'container/TopNav/AutoRefreshV2/AutoRefreshTicker';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
@@ -78,10 +77,7 @@ function DashboardContainer({
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>
{fullScreenHandle.active ? (
// The hidden toolbar owns the auto-refresh timer.
<AutoRefreshTicker />
) : (
{!fullScreenHandle.active && (
<>
<DashboardPageHeader title={name} image={image} />
<DashboardPageToolbar dashboard={dashboard} handle={fullScreenHandle} />

View File

@@ -58,15 +58,14 @@ function SettingsPage(): JSX.Element {
if (trialInfo?.workSpaceBlock && !isFetchingActiveLicense) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
!!(
isAdmin &&
(item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.MY_SETTINGS ||
item.key === ROUTES.SHORTCUTS)
),
isEnabled: !!(
isAdmin &&
(item.key === ROUTES.BILLING ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.MY_SETTINGS ||
item.key === ROUTES.SHORTCUTS)
),
}));
return updatedItems;
@@ -77,7 +76,6 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
@@ -91,6 +89,7 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
@@ -128,7 +127,6 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.ROLE_CREATE ||
item.key === ROUTES.ROLE_DETAILS ||
@@ -142,6 +140,7 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||

View File

@@ -73,13 +73,17 @@ describe('SettingsPage nav sections', () => {
});
});
it.each(['workspace', 'account', 'roles', 'service-accounts', 'billing'])(
it.each(['workspace', 'account', 'roles', 'service-accounts'])(
'renders "%s" element',
(id) => {
expect(screen.getByTestId(id)).toBeInTheDocument();
},
);
it.each(['billing'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
it('renders "mcp-server" element', () => {
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
});

View File

@@ -33,20 +33,14 @@ export const getRoutes = (
const isAdmin = userRole === USER_ROLES.ADMIN;
const isEditor = userRole === USER_ROLES.EDITOR;
if (isWorkspaceBlocked) {
if (isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
);
}
settings.push(...billingSettings(t));
if (isAdmin) {
settings.push(...keyboardShortcuts(t));
}
if (isWorkspaceBlocked && isAdmin) {
settings.push(
...organizationSettings(t),
...membersSettings(t),
...mySettings(t),
...billingSettings(t),
...keyboardShortcuts(t),
);
return settings;
}
@@ -79,7 +73,7 @@ export const getRoutes = (
settings.push(...membersSettings(t));
}
if (isCloudUser || isEnterpriseSelfHostedUser) {
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
settings.push(...billingSettings(t));
}

View File

@@ -4,12 +4,9 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Button, Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
import updateCreditCardApi from 'api/v1/checkout/create';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import {
ArrowUpRight,
Book,
@@ -21,6 +18,8 @@ import {
X,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
@@ -117,7 +116,9 @@ export default function Support(): JSX.Element {
const showAddCreditCardModal =
!isPremiumChatSupportEnabled && !trialInfo?.trialConvertedToSubscription;
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -135,7 +136,7 @@ export default function Support(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
updateCreditCardApi,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -245,23 +246,18 @@ export default function Support(): JSX.Element {
>
Cancel
</Button>,
<AuthZTooltip
<Button
key="submit"
checks={[SubscriptionCreatePermission]}
withPortal={false}
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn periscope-btn primary"
>
<Button
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn periscope-btn primary"
>
Add Credit Card
</Button>
</AuthZTooltip>,
Add Credit Card
</Button>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
let capturedPayload: QueryRangePayloadV5;
describe('TracesExplorer -', () => {
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
const setupServer = (): void => {
server.use(

View File

@@ -1,11 +1,7 @@
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { licensesSuccessWorkspaceLockedResponse } from 'mocks-server/__mockdata__/licenses';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { act, render, screen, waitFor } from 'tests/test-utils';
import { act, render, screen } from 'tests/test-utils';
import WorkspaceLocked from '.';
@@ -34,37 +30,40 @@ describe('WorkspaceLocked', () => {
expect(contactUsBtn).toBeInTheDocument();
});
it('enables the upgrade action when subscription create is granted', async () => {
it('Render for Admin', async () => {
server.use(
rest.get(apiURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
),
setupAuthzAdmin(),
);
render(<WorkspaceLocked />);
const contactAdminMessage = await screen.queryByText(
/contact your admin to proceed with the upgrade./i,
);
expect(contactAdminMessage).not.toBeInTheDocument();
const updateCreditCardBtn = await screen.findByRole('button', {
name: /continue my journey/i,
});
await waitFor(() => {
expect(updateCreditCardBtn).toBeEnabled();
});
expect(updateCreditCardBtn).toBeInTheDocument();
});
it('disables the upgrade action when subscription create is denied', async () => {
it('Render for non Admin', async () => {
server.use(
rest.get(apiURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(licensesSuccessWorkspaceLockedResponse)),
),
setupAuthzDenyAll(),
);
render(<WorkspaceLocked />, {}, { role: 'VIEWER' });
const updateCreditCardBtn = await screen.findByRole('button', {
name: /continue my journey/i,
});
await waitFor(() => {
expect(updateCreditCardBtn).toBeDisabled();
const updateCreditCardBtn = await screen.queryByRole('button', {
name: /Continue My Journey/i,
});
expect(updateCreditCardBtn).not.toBeInTheDocument();
const contactAdminMessage = await screen.findByText(
/contact your admin to proceed with the upgrade./i,
);
expect(contactAdminMessage).toBeInTheDocument();
});
});

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import type { TabsProps } from 'antd';
import {
Alert,
Button,
Col,
Collapse,
@@ -17,13 +18,11 @@ import {
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { createSubscription } from 'api/generated/services/subscriptions';
import updateCreditCardApi from 'api/v1/checkout/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import history from 'lib/history';
import { CircleArrowRight } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -45,7 +44,9 @@ import {
import './WorkspaceLocked.styles.scss';
export default function WorkspaceBlocked(): JSX.Element {
const { isFetchingActiveLicense, trialInfo, activeLicense } = useAppContext();
const { user, isFetchingActiveLicense, trialInfo, activeLicense } =
useAppContext();
const isAdmin = user.role === 'ADMIN';
const { notifications } = useNotifications();
const { safeNavigate } = useSafeNavigate();
@@ -88,7 +89,7 @@ export default function WorkspaceBlocked(): JSX.Element {
]);
const { mutate: updateCreditCard, isLoading } = useMutation(
createSubscription,
updateCreditCardApi,
{
onSuccess: (data) => {
if (data.data?.redirectURL) {
@@ -183,11 +184,8 @@ export default function WorkspaceBlocked(): JSX.Element {
/>
</Space>
</Col>
<Col span={24}>
<AuthZTooltip
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
{isAdmin && (
<Col span={24}>
<Button
type="primary"
shape="round"
@@ -197,8 +195,8 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</AuthZTooltip>
</Col>
</Col>
)}
</Row>
</Col>
</Row>
@@ -222,9 +220,9 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{renderCustomerStories((index) => index % 2 !== 0)}
</Col>
<Col span={24}>
<Flex justify="center">
<AuthZTooltip checks={[SubscriptionCreatePermission]} withPortal={false}>
{isAdmin && (
<Col span={24}>
<Flex justify="center">
<Button
type="primary"
shape="round"
@@ -234,9 +232,9 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</AuthZTooltip>
</Flex>
</Col>
</Flex>
</Col>
)}
</Row>
),
},
@@ -262,7 +260,7 @@ export default function WorkspaceBlocked(): JSX.Element {
defaultActiveKey={['signoz-cloud-vs-community']}
onChange={handleCollapseChange}
/>
<AuthZTooltip checks={[SubscriptionCreatePermission]} withPortal={false}>
{isAdmin && (
<Button
type="primary"
shape="round"
@@ -272,7 +270,7 @@ export default function WorkspaceBlocked(): JSX.Element {
>
{t('continueToUpgrade')}
</Button>
</AuthZTooltip>
)}
</Space>
</Col>
</Row>
@@ -290,19 +288,21 @@ export default function WorkspaceBlocked(): JSX.Element {
{t('trialPlanExpired')}
</span>
<span className="workspace-locked__modal__header__actions">
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={(e): void => handleViewBilling(e)}
>
View Billing
</Button>
{isAdmin && (
<Flex gap={8} justify="center" align="center">
<Button
className="workspace-locked__modal__header__actions__billing"
type="link"
size="small"
role="button"
onClick={(e): void => handleViewBilling(e)}
>
View Billing
</Button>
<RefreshPaymentStatus withPortal={false} />
</Flex>
<RefreshPaymentStatus />
</Flex>
)}
<Button
type="default"
@@ -346,7 +346,7 @@ export default function WorkspaceBlocked(): JSX.Element {
</Space>
</Col>
</Row>
<Flex gap={8} vertical justify="center" align="center">
{!isAdmin && (
<Row
justify="center"
align="middle"
@@ -354,10 +354,22 @@ export default function WorkspaceBlocked(): JSX.Element {
gutter={[8, 8]}
>
<Col>
<AuthZTooltip
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Flex gap={8} vertical justify="center" align="center">
<Row
justify="center"
align="middle"
className="workspace-locked__modal__cta"
gutter={[8, 8]}
>
<Col>
<Button
type="primary"
shape="round"
@@ -367,21 +379,21 @@ export default function WorkspaceBlocked(): JSX.Element {
>
Continue my Journey
</Button>
</AuthZTooltip>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
</Col>
<Col>
<Button
type="default"
shape="round"
size="middle"
className="periscope-btn"
onClick={handleExtendTrial}
>
{t('needMoreTime')}
</Button>
</Col>
</Row>
</Flex>
)}
<div className="workspace-locked__tabs">
<Tabs

View File

@@ -1,14 +1,12 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { Button, Col, Flex, Modal, Row, Skeleton, Space } from 'antd';
import { Alert, Button, Col, Flex, Modal, Row, Skeleton, Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { updateSubscription } from 'api/generated/services/subscriptions';
import manageCreditCardApi from 'api/v1/portal/create';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
@@ -20,13 +18,15 @@ import featureGraphicCorrelationUrl from '@/assets/Images/feature-graphic-correl
import './WorkspaceSuspended.styles.scss';
function WorkspaceSuspended(): JSX.Element {
const { user } = useAppContext();
const isAdmin = user.role === 'ADMIN';
const { notifications } = useNotifications();
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const { t } = useTranslation(['failedPayment']);
const { mutate: manageCreditCard, isLoading } = useMutation(
updateSubscription,
manageCreditCardApi,
{
onSuccess: (data) => {
if (data.data?.redirectURL) {
@@ -111,17 +111,29 @@ function WorkspaceSuspended(): JSX.Element {
</Space>
</Col>
</Row>
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[8, 8]}
>
<Flex gap={8} justify="center" align="center">
<AuthZTooltip
checks={SubscriptionManagePermissions}
withPortal={false}
>
{!isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[16, 16]}
>
<Col>
<Alert
message="Contact your admin to proceed with the upgrade."
type="info"
/>
</Col>
</Row>
)}
{isAdmin && (
<Row
justify="center"
align="middle"
className="workspace-suspended__modal__cta"
gutter={[8, 8]}
>
<Flex gap={8} justify="center" align="center">
<Button
type="primary"
shape="round"
@@ -131,10 +143,10 @@ function WorkspaceSuspended(): JSX.Element {
>
{t('continueMyJourney')}
</Button>
</AuthZTooltip>
<RefreshPaymentStatus withPortal={false} />
</Flex>
</Row>
<RefreshPaymentStatus />
</Flex>
</Row>
)}
<div className="workspace-suspended__creative">
<img src={featureGraphicCorrelationUrl} alt="correlation-graphic" />
</div>

View File

@@ -475,7 +475,6 @@ export function QueryBuilderProvider({
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({
existNames,

View File

@@ -1,55 +0,0 @@
import {
initialQueriesMap,
initialQueryAIWithType,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery builderQueryType propagation', () => {
it('carries builderQueryType from the first query onto an added query', () => {
const { result } = renderQueryBuilder(initialQueryAIWithType);
expect(
result.current.currentQuery.builder.queryData[0].builderQueryType,
).toBe('builder_ai_query');
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBe('builder_ai_query');
});
it('leaves builderQueryType unset when the first query has none', () => {
const { result } = renderQueryBuilder(initialQueriesMap.traces);
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBeUndefined();
});
});

View File

@@ -0,0 +1,12 @@
export interface CheckoutSuccessPayloadProps {
redirectURL: string;
}
export interface CheckoutRequestPayloadProps {
url: string;
}
export interface PayloadProps {
data: CheckoutSuccessPayloadProps;
status: string;
}

View File

@@ -8,7 +8,6 @@ import {
} from 'types/common/queryBuilder';
import {
BuilderQueryType,
Filter,
Having as HavingV5,
LogAggregation,
@@ -91,7 +90,6 @@ export type IBuilderQuery = {
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
builderQueryType?: BuilderQueryType;
};
export interface IClickHouseQuery {

View File

@@ -0,0 +1,14 @@
export interface Filter {
key: string;
dataType: string;
type: string;
}
export interface Props {
signal: string;
}
export type PayloadProps = {
filters: Filter[];
signal: string;
};

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