Compare commits

..

7 Commits

Author SHA1 Message Date
vikrantgupta25
bff855a05f fix: require attach grants to assign roles at user creation
POST /api/v2/users accepts userRoles in the payload, so creating a user
is also an attach between the new user and each role. The route now
declares a sibling attach def alongside the create def: attach on the
user resource (collection-level, the instance does not exist yet) and
attach on every role id in userRoles, resolved to role names. An absent
or empty userRoles list falls back to the collection-level role check,
per the resolved-resource contract (empty id means collection-level).
2026-09-08 02:23:29 +05:30
vikrantgupta25
337e62c775 feat(authz): enable FGA for the users API
#### Description

- Moves the users API off the legacy AdminAccess role gate onto
  CheckResources + ResourceDefs, so the routes work with per-resource
  OpenFGA checks on enterprise and keep the signoz-admin role gate on
  community:
  - /api/v2/users CRUD checks the user resource (create/list wildcard,
    read/update/delete per-instance).
  - /api/v2/users/{id}/reset_password_tokens checks the factor-password
    metaresource (read/create) against the user instance.
  - /api/v2/users/{id}/roles and /api/v2/user_roles check user
    read/attach/detach; the role side of the assignments is checked via
    the sibling attach/detach def, resolving role names from role ids.
  - /api/v2/roles/{id}/users checks role read.
  - Self-service and anonymous flows (users/me, password
    forgot/reset/verify/change) stay OpenAccess.
- The user, role and factor-password kinds/resources/managed-role
  transactions already existed in the registries, so no registry or
  schema changes are needed.
- Migration 126_add_user_tuples backfills the admin user and
  factor-password tuples for existing organizations; new organizations
  get them from the registry at bootstrap. The managed-role transaction
  groups stored per org already carry these transactions, so no group
  re-sync is needed.
2026-09-08 02:01:29 +05:30
Vikrant Gupta
3931c7163f chore(licensing): remove the legacy billing endpoints (#12771)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Removes the legacy `/api/v1/checkout`, `/api/v1/billing`, and
`/api/v1/portal` routes and the licensing API layer that served them,
now that billing goes through the subscription endpoints.

#### Issues closed by this PR

Closes SigNoz/platform-pod#3091
2026-09-07 18:58:03 +00:00
Vikrant Gupta
fdfbf77d2b feat(billing): gate billing on subscription permissions (#12769)
#### Description

- Billing is now authorized through the `subscription` permissions
instead of the admin role, so custom roles can be granted billing
access.
- Denied users keep the page, tab, and controls in view; usage shows the
denied callout and checkout/portal actions are disabled with a tooltip.
- Billing calls the `/api/v1/subscriptions` endpoints added in #12767
through the generated client.

#### Additional Information

Part of SigNoz/platform-pod#3091. Stacked on #12767; retarget to `main`
once that merges.
2026-09-07 17:29:43 +00:00
Abhi kumar
56e7388677 fix(dashboards-v2): keep auto refresh running in full screen and the table page size sticky (#12776)
#### Description

- Auto refresh stopped in full screen: the timer lived inside the time
selector, which the full-screen branch unmounts along with the toolbar,
so the global time window never advanced and panels never refetched. The
timer now lives in a hook plus a headless `AutoRefreshTicker`, mounted
in the full-screen branch so exactly one timer runs in either mode.
- Table panel page size always snapped back to 10: the height-derived
page size was passed straight into antd's controlled
`pagination.pageSize` and `onChange` dropped the new size. A size picked
from the size changer now wins; panels that were never touched still
auto-fit to the panel height on resize.

Closes https://github.com/SigNoz/pulse-pod/issues/329
2026-09-07 16:29:46 +00:00
Naman Verma
861380dc65 feat: add create v2 api for notification channels (#12638)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This PR adds one endpoint, `POST /api/v2/notification_channels`, whose
typed `{name, displayName, config:{kind, spec}}` body replaces v1's
pass-through Alertmanager receiver JSON. v1 routes are deliberately
untouched, so the diff is near-purely additive.
List/get/update/delete/test come in the next PR.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/296

<!--Anything reviewers should keep in mind while reviewing -->

Eventually references (rules, routing policies) migrate onto
internal_name, freeing name to become a user-editable display name. But
that will happen post rules migration so that all rules are on v2

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-07 11:36:12 +00:00
Gaurav Tewari
391f685e57 feat(llm-observability): AI explorer QB and builder_ai_query wiring (#12683)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds the `builder_ai_query` envelope type, seeded by the AI explorer
via `initialQueryAIWithType`.
- AI queries pull filter-bar keys from
`/api/v1/ai_observability/fields/keys` and values from
`/api/v1/ai_observability/fields/values`.
- `QuerySection` drops the builder's custom order-by (`ExplorerOrderBy`
via `renderOrderBy`) for the list and trace panels, along with
`showTraceOperator`. Ordering for those views is the list's own concern,
not the builder's — the trace view supplies its own control in #12688.
`limit` and `having` are now rendered disabled on every panel instead of
hidden on list.
- Hides the span-scope selector for AI queries — "Root / Entrypoint
Spans" ANDs badly with the gate, since GenAI attributes sit on nested
spans. Derived from the query itself, so no new prop on the shared
builder.
- `createNewBuilderQuery` inherits `builderQueryType` from the first
query, the way it already inherits `source`.
- Response conversion reads aggregation metadata from both builder
envelope types, not just `builder_query`.

TDD -
https://app.notion.com/p/signoz/Query-Builder-Changes-3b4fcc6bcd1980889c7aed782665e78d

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=226748421&issue=SigNoz%7Cengineering-pod%7C5889

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/985cb178-ffb0-405c-b0a6-82ec242c3d68

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-07 07:29:31 +00:00
177 changed files with 7054 additions and 3415 deletions

View File

@@ -97,6 +97,7 @@ 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

@@ -25,6 +25,379 @@ 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:
@@ -54,6 +427,30 @@ 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:
@@ -356,6 +753,19 @@ 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:
@@ -19309,6 +19719,69 @@ 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
@@ -19835,9 +20308,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- role:read
- tokenizer:
- ADMIN
- role:read
summary: Get users by role id
tags:
- users
@@ -24399,9 +24872,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:attach
- role:attach
summary: Create user role
tags:
- users
@@ -24451,9 +24926,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:detach
- role:detach
- tokenizer:
- ADMIN
- user:detach
- role:detach
summary: Delete user role
tags:
- users
@@ -24514,9 +24991,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user role
tags:
- users
@@ -24562,9 +25039,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:list
- tokenizer:
- ADMIN
- user:list
summary: List users v2
tags:
- users
@@ -24624,9 +25101,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:create
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:create
- user:attach
- role:attach
summary: Create user
tags:
- users
@@ -24670,9 +25151,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:delete
- tokenizer:
- ADMIN
- user:delete
summary: Delete user
tags:
- users
@@ -24727,9 +25208,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user by user id
tags:
- users
@@ -24783,9 +25264,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:update
- tokenizer:
- ADMIN
- user:update
summary: Update user v2
tags:
- users
@@ -24841,9 +25322,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:read
- tokenizer:
- ADMIN
- factor-password:read
summary: Get reset password token for a user
tags:
- users
@@ -24906,9 +25387,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:create
- tokenizer:
- ADMIN
- factor-password:create
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -24966,9 +25447,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user roles
tags:
- users

View File

@@ -1,85 +0,0 @@
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,12 +2,9 @@ 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"
@@ -228,47 +225,6 @@ 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,7 +4,6 @@ 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"
@@ -42,7 +41,6 @@ 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)
@@ -72,10 +70,6 @@ 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

@@ -1,76 +0,0 @@
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

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

View File

@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.MY_SETTINGS);
});
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
it('should allow VIEWER to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.BILLING,
appContext: {
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.BILLING);
});
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 redirect EDITOR to workspace locked when trying to access settings', async () => {
it('should allow EDITOR to access /settings when workspace is blocked', () => {
renderPrivateRoute({
initialRoute: ROUTES.SETTINGS,
appContext: {
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
assertStaysOnRoute(ROUTES.SETTINGS);
});
it('should not redirect when already on workspace locked page', () => {
@@ -1626,6 +1626,7 @@ 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

@@ -1,59 +0,0 @@
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,8 +19,10 @@ import type {
import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
@@ -647,3 +649,87 @@ 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,6 +37,476 @@ 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;
}
@@ -88,6 +558,32 @@ 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
@@ -1748,6 +2244,22 @@ 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
@@ -12650,6 +13162,14 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,28 +0,0 @@
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

@@ -1,28 +0,0 @@
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,8 +2,10 @@ 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,
@@ -11,6 +13,11 @@ 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>,
@@ -409,21 +416,19 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
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>,
) || {};
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>,
) || {};
// 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,6 +14,7 @@ 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';
@@ -935,3 +936,41 @@ 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: 'builder_query' as QueryType,
type: queryData.builderQueryType ?? 'builder_query',
spec,
};
},

View File

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

View File

@@ -4,16 +4,17 @@ 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 updateCreditCardApi from 'api/v1/checkout/create';
import { createSubscription } from 'api/generated/services/subscriptions';
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
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';
@@ -118,9 +119,7 @@ function LaunchChatSupport({
}
};
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -138,7 +137,7 @@ function LaunchChatSupport({
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -193,18 +192,23 @@ function LaunchChatSupport({
>
Cancel
</Button>,
<Button
<AuthZTooltip
key="submit"
type="primary"
icon={<CreditCard size={16} />}
size="middle"
loading={isLoadingBilling}
disabled={isLoadingBilling}
onClick={handleAddCreditCard}
className="add-credit-card-btn"
checks={[SubscriptionCreatePermission]}
withPortal={false}
>
Add Credit Card
</Button>,
<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>,
]}
>
<Typography.Text className="add-credit-card-text">

View File

@@ -16,8 +16,6 @@ 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,
@@ -54,6 +52,12 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -261,10 +265,8 @@ function QuerySearch({
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -317,8 +319,9 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -360,6 +363,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -493,10 +497,11 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -601,6 +606,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -0,0 +1,215 @@
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

@@ -0,0 +1,111 @@
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 } = query;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -4,14 +4,18 @@ 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();
@@ -36,17 +40,25 @@ function RefreshPaymentStatus({
};
const button = (
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
<AuthZTooltip
checks={
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
}
enabled={!!activeLicense}
withPortal={withPortal}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
<Button
variant="link"
color={type === 'text' ? 'none' : 'secondary'}
size="md"
className={className}
onClick={handleRefreshPaymentStatus}
prefix={<RefreshCcw size={14} />}
loading={isLoading}
>
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
</Button>
</AuthZTooltip>
);
return (
@@ -62,6 +74,7 @@ function RefreshPaymentStatus({
RefreshPaymentStatus.defaultProps = {
type: 'button',
className: undefined,
withPortal: undefined,
};
export default RefreshPaymentStatus;

View File

@@ -348,6 +348,19 @@ 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,7 +15,6 @@ 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

@@ -16,11 +16,13 @@ import * as Sentry from '@sentry/react';
import { Toaster } from '@signozhq/ui/sonner';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Flex } from 'antd';
import { Button } from '@signozhq/ui/button';
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 manageCreditCardApi from 'api/v1/portal/create';
import { updateSubscription } from 'api/generated/services/subscriptions';
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import getUserVersion from 'api/v1/version/get';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
@@ -30,6 +32,8 @@ 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';
@@ -63,8 +67,7 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
ChangelogSchema,
DeploymentType,
@@ -77,7 +80,6 @@ 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';
@@ -166,9 +168,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
return Math.abs(currentDate.diff(userCreationDate, 'day'));
}, [user.createdAt]);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -186,7 +186,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
};
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -469,10 +469,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [isLoggedIn]);
const handleUpgrade = useCallback((): void => {
if (user.role === USER_ROLES.ADMIN) {
history.push(ROUTES.BILLING);
}
}, [user.role]);
history.push(ROUTES.BILLING);
}, []);
const handleFailedPayment = useCallback((): void => {
manageCreditCard({
@@ -586,25 +584,21 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<div>
Our systems are taking longer than expected for your trial workspace.
Please{' '}
{user.role === USER_ROLES.ADMIN ? (
<span>
<a
className="upgrade-link"
onClick={(): void => {
notifications.destroy('slow-api-warning');
<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>
) : (
'contact your administrator for upgrading to a paid plan for a smoother experience.'
)}
handleUpgrade();
}}
>
upgrade
</a>
your workspace for a smoother experience.
</span>
</div>
),
duration: 60000,
@@ -794,22 +788,18 @@ 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>
{user.role === USER_ROLES.ADMIN ? (
<span>
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleUpgrade}>
upgrade
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
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>
| Already upgraded? <RefreshPaymentStatus type="text" />
</span>
) : (
'Please contact your administrator for upgrading to a paid plan.'
)}
</span>
</div>
)}
@@ -826,22 +816,25 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
.
</span>
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
<span>
{' '}
Please{' '}
<AuthZTooltip checks={SubscriptionManagePermissions}>
<Button
variant="link"
color="none"
className="upgrade-link"
onClick={handleFailedPayment}
>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</Button>
</AuthZTooltip>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</span>
</div>
)}
</div>

View File

@@ -0,0 +1,113 @@
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-8);
margin-bottom: var(--spacing-4);
.pageHeaderTitle {
font-weight: var(--label-medium-500-font-weight);
@@ -41,6 +41,8 @@
}
.pageInfo {
margin-bottom: var(--spacing-4);
:global(.ant-card) {
padding: var(--padding-3);
}
@@ -59,7 +61,7 @@
}
.billingDetails {
margin: var(--spacing-12) 0;
margin: var(--spacing-4) 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
overflow: hidden;
@@ -128,7 +130,7 @@
}
.upgradePlanBenefits {
margin: 0 var(--spacing-4);
margin: 0;
border: 1px solid var(--l1-border);
border-radius: 5px;
padding: 0 var(--padding-12);
@@ -176,7 +178,7 @@
}
.billingUpdateNote {
margin-top: var(--spacing-8);
margin-top: var(--spacing-4);
font-family: var(--font-family-inter);
font-size: var(--font-size-sm);
font-style: normal;

View File

@@ -1,9 +1,11 @@
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 {
@@ -15,11 +17,6 @@ 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(() => ({
@@ -31,14 +28,22 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
it('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = screen.getByRole('columnheader', {
const dataInjection = await screen.findByRole('columnheader', {
name: /data ingested/i,
});
expect(dataInjection).toBeInTheDocument();
const pricePerUnit = screen.getByRole('columnheader', {
const pricePerUnit = await screen.findByRole('columnheader', {
name: /price per unit/i,
});
expect(pricePerUnit).toBeInTheDocument();
@@ -49,13 +54,15 @@ 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);
const dollar = await screen.findByText(/\$1,278.3/i, {}, { timeout: 5000 });
expect(dollar).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
@@ -86,7 +93,9 @@ describe('BillingContainer', () => {
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/\$0/i, {}, { timeout: 5000 }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(
@@ -132,7 +141,7 @@ describe('BillingContainer', () => {
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
const dollar0 = await screen.findByText(/\$0/i, {}, { timeout: 5000 });
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
@@ -250,7 +259,11 @@ describe('BillingContainer', () => {
billingSuccessResponse.data.billingPeriodStart,
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
const billingPeriod = await findByText(billingPeriodText);
const billingPeriod = await findByText(
billingPeriodText,
{},
{ timeout: 5000 },
);
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, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import {
Card,
@@ -15,25 +15,35 @@ 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 updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import type {
CreateSubscription201,
GetSubscription200,
SubscriptiontypesGettableSubscriptionUsageDTO,
SubscriptiontypesSubscriptionUsageBreakdownDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
createSubscription,
updateSubscription,
useGetSubscription,
} from 'api/generated/services/subscriptions';
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 useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
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 {
SubscriptionCreatePermission,
SubscriptionManagePermissions,
SubscriptionReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
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';
@@ -135,7 +145,7 @@ export default function BillingContainer(): JSX.Element {
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<DataType[]>([]);
const [apiResponse, setApiResponse] = useState<
Partial<UsageResponsePayloadProps>
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
>({});
const {
@@ -146,7 +156,8 @@ export default function BillingContainer(): JSX.Element {
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { licenseKey } = useActiveLicenseKey();
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
useAuthZ([SubscriptionReadPermission]);
const { notifications } = useNotifications();
const handleError = useAxiosError();
@@ -154,33 +165,34 @@ export default function BillingContainer(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const processUsageData = useCallback(
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
if (isEmpty(data?.payload)) {
(response: GetSubscription200): void => {
const usage = response?.data;
if (isEmpty(usage)) {
return;
}
const {
details: { breakdown = [], billTotal },
billingPeriodStart,
billingPeriodEnd,
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
const breakdown = usage.details?.breakdown ?? [];
const billTotal = usage.details?.billTotal ?? 0;
const billingPeriodStart = usage.billingPeriodStart ?? 0;
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
const formattedUsageData: DataType[] = [];
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) => {
breakdown.forEach(
(
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
index: number,
) => {
element?.tiers?.forEach((tier, tierIndex: number) => {
formattedUsageData.push({
key: `${index}${i}`,
name: i === 0 ? element?.type : '',
key: `${index}${tierIndex}`,
name: tierIndex === 0 ? (element?.type ?? '') : '',
unit: element?.unit ?? '',
dataIngested: `${tier.quantity} ${element?.unit}`,
pricePerUnit: String(tier.unitPrice),
cost: `$ ${tier.tierCost}`,
});
});
}
}
},
);
setData(formattedUsageData);
@@ -196,7 +208,7 @@ export default function BillingContainer(): JSX.Element {
setBillAmount(billTotal);
}
setApiResponse(data?.payload || {});
setApiResponse(usage);
},
[trialInfo?.onTrial],
);
@@ -208,11 +220,12 @@ export default function BillingContainer(): JSX.Element {
isLoading,
isFetching: isFetchingBillingData,
data: billingData,
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
queryFn: () => getUsage(licenseKey || ''),
onError: handleError,
enabled: !!licenseKey,
onSuccess: processUsageData,
} = useGetSubscription({
query: {
enabled: canReadSubscription || !!subscriptionAuthZError,
onError: handleError,
onSuccess: processUsageData,
},
});
useEffect(() => {
@@ -284,9 +297,7 @@ export default function BillingContainer(): JSX.Element {
/>
);
const handleBillingOnSuccess = (
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.data.redirectURL;
@@ -303,7 +314,7 @@ export default function BillingContainer(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
updateCreditCardApi,
createSubscription,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -313,7 +324,7 @@ export default function BillingContainer(): JSX.Element {
);
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(manageCreditCardApi, {
useMutation(updateSubscription, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -348,15 +359,21 @@ 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 `}
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
<AuthZTooltip checks={billingActionPermissions}>
<Typography.Link
onClick={handleBilling}
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
>
{t('here')}
</Typography.Link>
</AuthZTooltip>
{` 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.`}
@@ -411,11 +428,7 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Flex>
<Card
bordered={false}
style={{ minHeight: 150, marginBottom: 16 }}
className={styles.pageInfo}
>
<Card bordered={false} className={styles.pageInfo}>
<Flex justify="space-between" align="center">
<Flex vertical gap={8}>
<p className={styles.pageInfoTitle}>
@@ -423,13 +436,14 @@ export default function BillingContainer(): JSX.Element {
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
</p>
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
<p className={styles.pageInfoSubtitle}>
{daysRemaining} {daysRemainingStr}
</p>
) : null}
</Flex>
<Button
<AuthZButton
checks={billingActionPermissions}
testId="header-billing-button"
variant="solid"
color="secondary"
@@ -443,7 +457,7 @@ export default function BillingContainer(): JSX.Element {
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</Button>
</AuthZButton>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -495,66 +509,73 @@ export default function BillingContainer(): JSX.Element {
))}
</Card>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
<AuthZGuardContent checks={[SubscriptionReadPermission]}>
<>
<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>
)}
</div>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
{!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.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>
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
</>
</AuthZGuardContent>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
@@ -597,7 +618,8 @@ export default function BillingContainer(): JSX.Element {
</Typography.Text>
</Col>
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
<AuthZButton
checks={[SubscriptionCreatePermission]}
testId="upgrade-plan-button"
variant="solid"
color="primary"
@@ -606,7 +628,7 @@ export default function BillingContainer(): JSX.Element {
onClick={handleBilling}
>
{t('upgrade_plan')}
</Button>
</AuthZButton>
</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 { UsageResponsePayloadProps } from 'api/billing/getUsage';
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
@@ -25,7 +25,7 @@ import {
import styles from './BillingUsageGraph.module.scss';
interface BillingUsageGraphProps {
data: Partial<UsageResponsePayloadProps>;
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
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 + 86400,
timestamp: (currentDay.timestamp ?? 0) + 86400,
count: 0,
size: 0,
quantity: 0,
@@ -94,7 +94,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
const { startTime, endTime } = useMemo(
() =>
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
calculateStartEndTime(
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
),
[normalizedData],
);

View File

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

View File

@@ -1,3 +1,9 @@
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';
@@ -36,10 +42,24 @@ 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(
@@ -56,9 +76,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
@@ -76,9 +97,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
expect(confirmButton).toBeDisabled();
@@ -95,9 +117,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
const input = screen.getByTestId('cancel-confirm-input');
await user.type(input, 'cancel');
@@ -107,9 +130,10 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
});
@@ -119,9 +143,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -151,9 +176,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -172,9 +198,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
@@ -192,9 +219,10 @@ describe('CancelSubscriptionBanner', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await waitFor(() => {
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
});
await user.click(screen.getByTestId('cancel-subscription-btn'));
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));

View File

@@ -11,6 +11,8 @@ 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';
@@ -178,15 +180,17 @@ function CancelSubscriptionBanner(): JSX.Element {
immediately and removed from our servers.
</span>
</div>
<Button
<AuthZButton
checks={[SubscriptionDeletePermission]}
variant="solid"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
testId="cancel-subscription-btn"
>
Cancel Subscription
</Button>
</AuthZButton>
</div>
<DialogWrapper
open={dialogView !== null}

View File

@@ -11,17 +11,13 @@ 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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, 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';
@@ -52,6 +48,7 @@ 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';
@@ -118,7 +115,7 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
initialQueryAIWithType,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
@@ -185,7 +182,7 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],

View File

@@ -17,12 +17,11 @@ 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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, 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,
@@ -43,6 +42,7 @@ 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 || initialQueriesMap.traces, orderBy),
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);

View File

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

View File

@@ -14,10 +14,9 @@ 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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, 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';
@@ -31,6 +30,7 @@ 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 || initialQueriesMap.traces),
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
);

View File

@@ -0,0 +1,61 @@
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

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

View File

@@ -6,6 +6,7 @@ import {
Gauge,
Key,
Logs,
Receipt,
Shield,
} from '@signozhq/icons';
@@ -69,6 +70,13 @@ 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.',
@@ -107,7 +115,11 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
export const RESOURCE_ORDER = (
Object.keys(RESOURCE_PANELS) as AuthZResource[]
).sort((left, right) =>
RESOURCE_PANELS[left].label.localeCompare(RESOURCE_PANELS[right].label),
);
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
const panel = RESOURCE_PANELS[resource];

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,116 @@
// 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

@@ -0,0 +1,162 @@
// 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,7 +2,6 @@ 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';
@@ -11,21 +10,18 @@ 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,
UPDATE_TIME_INTERVAL,
} from 'types/actions/globalTime';
import { UPDATE_AUTO_REFRESH_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';
@@ -93,30 +89,10 @@ function AutoRefresh({
[selectedOption],
);
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);
useAutoRefreshTick(
!isDisabled && isAutoRefreshEnabled && selectedOption !== 'off',
getOption?.value || 0,
);
const onChangeHandler = useCallback(
(selectedValue: string) => {

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,47 @@
// 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,6 +13,11 @@ 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,3 +4,5 @@ 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

@@ -0,0 +1,26 @@
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

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

View File

@@ -5,11 +5,10 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
@@ -68,7 +67,7 @@ function ConfigPane({
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.

View File

@@ -5,18 +5,8 @@ import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -44,7 +34,6 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']

View File

@@ -2,8 +2,8 @@ import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
@@ -27,17 +27,17 @@ export function usePanelTypeSelectItems({
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind,
kind: panelKind,
queryType,
signal,
label: displayName,
label,
});
return {
value: kind,
label: displayName,
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,

View File

@@ -5,7 +5,7 @@ import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(

View File

@@ -1,7 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -8,14 +8,9 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
// Open the antd Select by clicking its selector, then pick the option by label.

View File

@@ -26,16 +26,3 @@
background: var(--l2-border);
}
}
// The static editor's preview: the panel card the grid shows, minus actions.
.staticPreviewSurface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}

View File

@@ -1,116 +0,0 @@
import type { ReactNode } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import layoutStorage from '../layoutStorage';
import styles from '../PanelEditor.module.scss';
/** A resizable pane's bounds, in the percentage strings `ResizablePanel` takes. */
interface PaneSize {
minSize: string;
maxSize: string;
defaultSize: string;
}
/** How the left column divides between the preview and the editor pane. */
export interface PaneSplit {
preview: PaneSize;
editor: PaneSize;
}
/**
* Vertical split per authoring mode. The query builder is a compact form, so the
* preview keeps the room; a static kind's editor pane is the surface being worked
* in, so it gets more and can grow further.
*/
export const PANE_SPLIT = {
query: {
preview: { minSize: '55%', maxSize: '65%', defaultSize: '60%' },
editor: { minSize: '35%', maxSize: '45%', defaultSize: '40%' },
},
static: {
preview: { minSize: '40%', maxSize: '65%', defaultSize: '55%' },
editor: { minSize: '35%', maxSize: '60%', defaultSize: '45%' },
},
} as const satisfies Record<string, PaneSplit>;
interface PanelEditorLayoutProps {
/** Save/close chrome — its affordances differ per authoring mode. */
header: ReactNode;
/** Upper-left: what the panel will look like once saved. */
preview: ReactNode;
/** Lower-left: the kind's `EditorPane` — the query builder, or a static kind's own. */
editor: ReactNode;
/** Right column: the kind's config sections. */
config: ReactNode;
split: PaneSplit;
}
/**
* The panel editor's frame: header, the resizable three-pane arrangement, and the
* persistence of what the user drags. Owned once so both authoring modes cannot
* drift apart on pane bounds or share a layout id by accident — they differ only in
* `split` and in what fills the slots.
*/
function PanelEditorLayout({
header,
preview,
editor,
config,
split,
}: PanelEditorLayoutProps): JSX.Element {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
return (
<div className={styles.page} data-testid="panel-editor-v2">
{header}
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel {...split.preview}>{preview}</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel {...split.editor}>{editor}</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
{config}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorLayout;

View File

@@ -21,15 +21,20 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
@@ -50,7 +55,7 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelDefinition,
panelKind,
signal,
isLoadingQueries,
onStageRunQuery,
@@ -60,10 +65,10 @@ function PanelEditorQueryBuilder({
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -94,12 +99,12 @@ function PanelEditorQueryBuilder({
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
[panelDefinition.queryBuilderFields, signal],
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -146,7 +151,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
return (
<div

View File

@@ -1,28 +0,0 @@
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
/**
* The default query-kind editor pane: the query-builder tabs with no extras. A
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
*/
function QueryBuilderEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
/>
);
}
export default QueryBuilderEditorPane;

View File

@@ -4,9 +4,6 @@ import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
@@ -51,7 +48,7 @@ function renderBuilder(
): void {
render(
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
panelKind={panelKind as never}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}

View File

@@ -6,7 +6,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -20,8 +20,8 @@ import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
panelDefinition: RenderableQueryPanelDefinition;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
@@ -107,7 +107,7 @@ function PreviewPane({
hideActions
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}

View File

@@ -1,331 +0,0 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface QueryEditorBodyProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** The draft kind's definition, narrowed by the shell's fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind editor body: a resizable split with the live preview + the
* kind's editor pane on the left and the config pane on the right. Draft and
* kind-switch state live in the shell; this body owns the query session and the
* save round-trip.
*/
function QueryEditorBody({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
draftApi,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const panelKind = draft.spec.plugin.kind;
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
const { EditorPane } = panelDefinition;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<PanelEditorLayout
split={PANE_SPLIT.query}
header={
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
}
preview={
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
}
editor={
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<EditorPane
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}
onChangeSpec={setSpec}
/>
</ConfigProvider>
}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
}
/>
);
}
export default QueryEditorBody;

View File

@@ -1,135 +0,0 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* Editor body for a kind that renders from its own plugin spec: the kind's
* editor pane under a live preview of the draft, the config pane on the right.
* No query session, no builder seeding, no staged-run — the preview re-renders
* from the draft spec on every edit.
*/
function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticEditorBodyProps): JSX.Element {
const { draft, spec, setSpec, isSpecDirty } = draftApi;
const { EditorPane } = panelDefinition;
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// `queries: []` is the only shape the API accepts for a static kind.
const savedPanelId = await save({ ...draft.spec, queries: [] });
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
return (
<PanelEditorLayout
split={PANE_SPLIT.static}
header={
<Header
isDirty={isSpecDirty}
isSaving={isSaving}
showSwitchToView={false}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onClose={onCloseEditor}
/>
}
preview={
<div className={styles.staticPreviewSurface}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
panelDefinition={panelDefinition}
panel={draft}
panelId={panelId}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
</div>
}
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>
}
/>
);
}
export default StaticEditorBody;

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
@@ -233,12 +232,7 @@ describe('PanelEditorContainer composition', () => {
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({
panelDefinition: expect.objectContaining({
kind: 'signoz/TimeSeriesPanel',
mode: 'query',
}),
}),
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -262,7 +256,7 @@ describe('PanelEditorContainer composition', () => {
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(

View File

@@ -18,8 +18,6 @@ import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
@@ -85,7 +83,7 @@ function EditorRoute(): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}

View File

@@ -19,10 +19,6 @@ jest.mock('lib/query/panelQuery', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isQuerylessPanelKind: jest.requireActual('../../../Panels/capabilities')
.isQuerylessPanelKind,
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),

View File

@@ -1,27 +1,36 @@
import { useMemo } from 'react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
type LegendSeries,
resolvePieLegendSeries,
resolveTimeSeriesLegendSeries,
} from '../utils/legendSeries';
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
* legend-colors control can key overrides by the exact labels the chart draws, using
* the resolver the kind declares as its `colors` control.
* legend-colors control can key overrides by the exact labels the chart draws. Only the
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
* Series from its flat series); every other kind returns none.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): LegendSeries[] {
const isDarkMode = useIsDarkMode();
const kind = panel.spec.plugin.kind;
return useMemo(() => {
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
return resolve
? resolve({ queries: panel.spec.queries, data, isDarkMode })
: [];
}, [kind, panel.spec.queries, data, isDarkMode]);
switch (panel.spec.plugin.kind) {
case 'signoz/PieChartPanel':
return resolvePieLegendSeries(data, isDarkMode);
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/HistogramPanel':
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
default:
return [];
}
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
}

View File

@@ -4,19 +4,24 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
@@ -33,12 +38,6 @@ interface UsePanelEditSessionArgs {
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
/**
* Externally-owned draft. The editor shell hoists it above its mode fork so a
* kind switch across modes survives the branch swap; hosts without a fork (the
* View modal, until it forks) omit it and the session owns the draft.
*/
draftApi?: PanelEditorDraftApi;
}
export interface UsePanelEditSessionReturn {
@@ -51,7 +50,7 @@ export interface UsePanelEditSessionReturn {
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderableQueryPanelDefinition;
panelDefinition: RenderablePanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
@@ -63,6 +62,8 @@ export interface UsePanelEditSessionReturn {
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -79,17 +80,14 @@ export function usePanelEditSession({
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
draftApi,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
// Called unconditionally (hooks rules); unused when a hoisted draft is passed in.
const internalDraftApi = usePanelEditorDraft(panel, savedPanel);
const { draft, spec, setSpec, isSpecDirty, reset } =
draftApi ?? internalDraftApi;
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const panelKind = draft.spec.plugin.kind;
// Hosts fork on `definition.mode` before mounting this session (the editor and
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelDefinition = getPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const defaultSignal = panelDefinition.supportedSignals[0];
@@ -111,6 +109,12 @@ export function usePanelEditSession({
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
@@ -124,5 +128,6 @@ export function usePanelEditSession({
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -18,10 +18,7 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import {
isQuerylessPanelKind,
resolveQueryType,
} from '../../Panels/capabilities';
import { resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -131,25 +128,11 @@ export function usePanelTypeSwitch({
queries,
});
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
if (!isQuerylessPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isQuerylessPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}

View File

@@ -1,13 +1,56 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
export interface PanelEditorContainerProps {
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -31,42 +74,293 @@ export interface PanelEditorContainerProps {
}
/**
* V2 panel editor page shell. Owns exactly the state that must survive a switch
* between authoring modes — the draft and the kind-switch cache — and forks on
* the draft kind's `mode`: query kinds get the session-backed body, static kinds
* an editor pane over a live preview with no query machinery at all.
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
*/
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { panel, savedPanel } = props;
const draftApi = usePanelEditorDraft(panel, savedPanel);
function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
setSpec: draftApi.setSpec,
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
if (panelDefinition.mode === 'static') {
return (
<StaticEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
}
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<QueryEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -2,9 +2,9 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
import { getBuilderQueries } from './getBuilderQueries';
import { resolveSeriesLabelV5 } from './resolveSeriesLabel';
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
@@ -22,15 +22,6 @@ export interface LegendSeries {
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
export interface LegendSeriesArgs {
queries: PanelQueries;
data: PanelQueryData;
isDarkMode: boolean;
}
/** Resolves a kind's output into the legend entries the colors control keys overrides by. */
export type LegendSeriesResolver = (args: LegendSeriesArgs) => LegendSeries[];
/**
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
* pairs, resolving each unique label's color lazily via `colorFor` so a repeated
@@ -57,10 +48,10 @@ function buildLegendSeries(
* draws (without overrides, so their colors are the defaults) so the color control keys
* overrides by the same labels the chart does.
*/
export function resolvePieLegendSeries({
data,
isDarkMode,
}: LegendSeriesArgs): LegendSeries[] {
export function resolvePieLegendSeries(
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const slices = preparePieData({
tables: prepareScalarTables({
results: getScalarResults(data.response),
@@ -79,11 +70,11 @@ export function resolvePieLegendSeries({
* Time-series kinds: resolve each flattened series' label the way the renderer does
* (`getLabelName` `resolveSeriesLabelV5`) and color it with `generateColor`.
*/
export function resolveTimeSeriesLegendSeries({
queries,
data,
isDarkMode,
}: LegendSeriesArgs): LegendSeries[] {
export function resolveTimeSeriesLegendSeries(
queries: PanelQueries,
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;

View File

@@ -11,8 +11,6 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
@@ -109,7 +107,7 @@ const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
@@ -151,8 +149,7 @@ describe('panel capabilities guard', () => {
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const queryCapabilities = requireQueryPanelDefinition(unknownKind)
.queryCapabilities;
const { queryCapabilities } = getPanelDefinition(unknownKind);
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);

View File

@@ -2,11 +2,7 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import {
mergeQueryBuilderFieldRule,
type FilterConfigsPartial,
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import type { PanelKind } from './types/panelKind';
/**
@@ -17,46 +13,11 @@ import type { PanelKind } from './types/panelKind';
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Renders from its own plugin spec — no query surface at all. */
export function isQuerylessPanelKind(kind: PanelKind): boolean {
return getPanelDefinition(kind).mode === 'static';
}
/**
* The kind's definition narrowed to the query arm, or null for a static kind.
* The null is what hosts fork on; the accessors below fold it into "supports
* nothing" for the guard questions.
*/
export function getQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition | null {
const definition = getPanelDefinition(kind);
return definition.mode === 'query' ? definition : null;
}
/**
* The query arm, asserted present. For call sites that a host mounts only after
* narrowing `mode === 'query'` but that read the definition by kind rather than
* receiving it as a prop — the throw makes that invariant executable instead of
* silently null-tolerant.
*/
export function requireQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition {
const definition = getQueryPanelDefinition(kind);
if (!definition) {
throw new Error(
`query machinery mounted for query-less panel kind ${kind} — the host must fork on definition.mode before this point`,
);
}
return definition;
}
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
return getPanelDefinition(kind).supportedSignals;
}
export function isSignalSupported(
@@ -68,7 +29,7 @@ export function isSignalSupported(
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
return getPanelDefinition(kind).supportedQueryTypes;
}
export function isQueryTypeSupportedByPanelKind(
@@ -92,10 +53,6 @@ export function isPanelCombinationValid({
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isQuerylessPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return false;
}
@@ -116,11 +73,7 @@ export function resolveQueryType(
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
return supported.includes(preferred) ? preferred : supported[0];
}
/**
@@ -132,6 +85,7 @@ export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
return mergeQueryBuilderFieldRule(rule, signal);
const rule = getPanelDefinition(kind).queryBuilderFields;
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}

View File

@@ -1,7 +1,4 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,3 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
@@ -14,10 +13,7 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },

View File

@@ -1,7 +1,4 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,3 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { SectionKind, type SectionConfig } from '../../types/sections';
@@ -10,7 +9,7 @@ export const sections: SectionConfig[] = [
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
controls: { position: true, colors: true },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
Boolean(

View File

@@ -1,34 +0,0 @@
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
import ListColumnsEditor from '../../../PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
/**
* List's editor pane: the query builder with the columns editor pinned below it.
* Declared here so no editor host carries a List special case.
*/
function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
spec,
onChangeSpec,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
footer={
<ListColumnsEditor spec={spec} onChangeSpec={onChangeSpec} signal={signal} />
}
/>
);
}
export default ListEditorPane;

View File

@@ -1,7 +1,4 @@
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -14,10 +11,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
mode: 'query',
icon: List,
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,

View File

@@ -1,7 +1,4 @@
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
mode: 'query',
icon: Hash,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,4 @@
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
mode: 'query',
icon: ChartPie,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,3 @@
import { resolvePieLegendSeries } from '../../utils/legendSeries';
import { SectionKind, type SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
@@ -9,9 +8,6 @@ export const sections: SectionConfig[] = [
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolvePieLegendSeries },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -153,6 +153,21 @@ 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}
@@ -170,10 +185,10 @@ function TablePanelRenderer({
dataSource={filteredDataSource}
pagination={{
current: page,
pageSize,
pageSize: effectivePageSize,
hideOnSinglePage: true,
size: 'small',
onChange: setPage,
onChange: handlePaginationChange,
}}
scroll={{ x: 'max-content', y: scrollY }}
/>

View File

@@ -1,3 +1,4 @@
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesTablePanelSpecDTO,
type QueryRangeV5200,
@@ -10,6 +11,7 @@ import type {
PanelOfKind,
PanelRendererProps,
} from '../../../types/rendererProps';
import { MIN_PAGE_SIZE } from '../../../utils/recordTable';
import TablePanelRenderer from '../Renderer';
function panelWith(
@@ -129,6 +131,27 @@ 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

@@ -1,7 +1,4 @@
import { Table } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
mode: 'query',
icon: Table,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,4 @@
import { ChartLine } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +10,7 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
mode: 'query',
icon: ChartLine,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,4 +1,3 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
@@ -12,10 +11,7 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.ChartAppearance,
controls: {

View File

@@ -1,11 +1,9 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { TriangleAlert } from '@signozhq/icons';
import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
/**
@@ -20,11 +18,7 @@ import Renderer from './Renderer';
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
mode: 'query',
// Never offered in the UI — the kind lists come from the registry, which omits this.
icon: TriangleAlert,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],

View File

@@ -7,33 +7,22 @@ import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelDefinition,
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import { PanelKind } from './types/panelKind';
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
// Declaration order is the order kinds are offered in the UI.
export const PANELS: PanelRegistry = {
[TimeSeries.kind]: TimeSeries,
[NumberValue.kind]: NumberValue,
[Table.kind]: Table,
[BarChart.kind]: BarChart,
[PieChart.kind]: PieChart,
[Histogram.kind]: Histogram,
[NumberValue.kind]: NumberValue,
[PieChart.kind]: PieChart,
[Table.kind]: Table,
[List.kind]: List,
};
export type PanelOption = Pick<
PanelDefinition,
'kind' | 'displayName' | 'icon'
>;
// Backs both the new-panel picker and the editor's kind switcher; derived from PANELS
// so a registered kind can't end up unreachable from the UI.
export const PANEL_OPTIONS: PanelOption[] = Object.values(PANELS);
/**
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
* but a dashboard spec written by a newer SigNoz can name one this client has never heard

View File

@@ -22,15 +22,6 @@ export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
export function mergeQueryBuilderFieldRule(
rule: QueryBuilderFieldRule,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code

View File

@@ -1,9 +1,5 @@
import type { ComponentType } from 'react';
import {
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
@@ -13,11 +9,7 @@ import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type {
BaseRendererProps,
PanelRendererProps,
StaticRendererProps,
} from './rendererProps';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
export enum DownloadFormat {
@@ -68,53 +60,11 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
drilldown: false,
};
// Derived from an icon component so the props stay exact (size is a constrained
// IconSize union) and ForwardRef-compatible.
export type PanelIcon = typeof ChartLine;
export interface PanelDefinitionBase<K extends PanelKind = PanelKind> {
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
icon: PanelIcon;
sections: SectionConfig[];
actions: PanelActionCapabilities;
}
/** Props for a static kind's authoring pane — rendered where the query builder sits. */
export interface StaticEditorPaneProps {
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* Props for a query kind's authoring pane (the editor's lower-left slot). Spec
* read/write is included so a kind's pane can edit its own spec slices (the List
* columns editor) without the host carrying per-kind conditionals.
*/
export interface QueryEditorPaneProps {
/** The kind's definition, narrowed by the host's fork. */
panelDefinition: RenderableQueryPanelDefinition;
signal: TelemetrytypesSignalDTO;
isLoadingQueries: boolean;
onStageRunQuery: () => void;
onCancelQuery: () => void;
/** Pin the tabs row to the pane top; the View modal opts out. */
stickyHeader?: boolean;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* A kind that renders from a query. Declares its whole query surface here, so a
* kind without one carries no query declarations at all — no dummy capabilities,
* no empty signal lists standing in for "not applicable".
*/
export interface QueryPanelDefinition<K extends PanelKind = PanelKind>
extends PanelDefinitionBase<K> {
mode: 'query';
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
sections: SectionConfig[];
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
@@ -123,38 +73,16 @@ export interface QueryPanelDefinition<K extends PanelKind = PanelKind>
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}
/**
* A kind that renders from its own plugin spec and saves with `queries: []` (the
* API rejects anything else). Its renderer takes no query data, and its editor
* pane replaces the query builder (TDD D8). No query machinery mounts for it
* anywhere — every host forks on `mode` before touching a query hook.
*/
export interface StaticPanelDefinition<K extends PanelKind = PanelKind>
extends PanelDefinitionBase<K> {
mode: 'static';
Renderer: ComponentType<StaticRendererProps<K>>;
EditorPane: ComponentType<StaticEditorPaneProps>;
}
export type PanelDefinition<K extends PanelKind = PanelKind> =
| QueryPanelDefinition<K>
| StaticPanelDefinition<K>;
// Every kind must be registered, so getPanelDefinition never returns undefined.
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
// The arms with their Renderer widened to the kind-agnostic prop surface. Declared
// explicitly rather than via `Omit` over the union, which collapses to common keys.
export interface RenderableQueryPanelDefinition extends Omit<
QueryPanelDefinition,
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}
export type RenderableStaticPanelDefinition = StaticPanelDefinition<PanelKind>;
export type RenderablePanelDefinition =
| RenderableQueryPanelDefinition
| RenderableStaticPanelDefinition;

View File

@@ -75,19 +75,6 @@ export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
};
};
/**
* Props a static (query-less) renderer receives: its panel and render context,
* nothing of the fetch lifecycle. `dashboardId` scopes store reads (resolved
* variables); the editor route seeds the same store, so previews of an unsaved
* panel resolve variables the way the grid does.
*/
export interface StaticRendererProps<K extends PanelKind = PanelKind> {
panelId: string;
panel: PanelOfKind<K>;
panelMode: PanelMode;
dashboardId?: string;
}
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
// and only the gestures it supports. The default K = PanelKind is the widest surface.

View File

@@ -13,7 +13,6 @@ import type {
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { LegendSeriesResolver } from '../utils/legendSeries';
import {
Antenna,
BarChart,
@@ -106,12 +105,7 @@ export interface SectionControls {
columnUnits?: boolean;
};
[SectionKind.Axes]: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
[SectionKind.Legend]: {
position?: boolean;
// colors → customColors; the resolver supplies the labels overrides are keyed by,
// so a kind can't offer color overrides with nothing to color
colors?: LegendSeriesResolver;
};
[SectionKind.Legend]: { position?: boolean; colors?: boolean }; // colors → customColors
[SectionKind.ChartAppearance]: {
lineStyle?: boolean;
lineInterpolation?: boolean;

View File

@@ -79,7 +79,7 @@ describe('buildPluginSpec', () => {
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
const result = buildPluginSpec([
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
{ kind: SectionKind.Legend, controls: { colors: true } },
]);
expect(result).toStrictEqual({});
@@ -129,7 +129,7 @@ describe('buildPluginSpec', () => {
it('seeds neither when their defaulting controls are absent', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
{ kind: SectionKind.Legend, controls: { colors: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
@@ -180,10 +180,7 @@ describe('buildPluginSpec', () => {
it('carries old legend position but never customColors', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Legend,
controls: { position: true, colors: (): [] => [] },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
];
const oldSpec = oldSpecWith({
legend: {

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