mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-10 13:30:42 +01:00
Compare commits
9 Commits
feat/stora
...
refactor/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61f668ca30 | ||
|
|
48b12c6247 | ||
|
|
a97e9838ad | ||
|
|
368b2a0648 | ||
|
|
46e6132297 | ||
|
|
f54a33c3ee | ||
|
|
c170707757 | ||
|
|
f6c34795a5 | ||
|
|
37ef1cd1db |
@@ -97,7 +97,6 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
|
||||
@@ -397,6 +397,7 @@ identn:
|
||||
# headers to use for tokenizer identN resolver
|
||||
headers:
|
||||
- Authorization
|
||||
- Sec-WebSocket-Protocol
|
||||
apikey:
|
||||
# toggle apikey identN
|
||||
enabled: true
|
||||
|
||||
@@ -25,379 +25,6 @@ components:
|
||||
- data
|
||||
- orgId
|
||||
type: object
|
||||
AlertmanagertypesChannelConfig:
|
||||
discriminator:
|
||||
mapping:
|
||||
email: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
|
||||
googlechat: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
|
||||
incidentio: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
jira: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
jsmops: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
msteams: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- email
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelEmailConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- googlechat
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelGoogleChatConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- incidentio
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelIncidentIOConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- jsmops
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelJSMOpsConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- jira
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelJiraConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- msteams
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelMSTeamsConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- opsgenie
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelOpsgenieConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- pagerduty
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelPagerdutyConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- slack
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- webhook
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelWebhookConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- to
|
||||
type: object
|
||||
AlertmanagertypesChannelGoogleChatConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
required:
|
||||
- webhookUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelIncidentIOConfig:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
- token
|
||||
type: object
|
||||
AlertmanagertypesChannelJSMOpsConfig:
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
apiToken:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
issueType:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopenDuration:
|
||||
type: string
|
||||
reopenTransition:
|
||||
type: string
|
||||
resolveTransition:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wontFixResolution:
|
||||
type: string
|
||||
required:
|
||||
- site
|
||||
- project
|
||||
- issueType
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelKind:
|
||||
enum:
|
||||
- slack
|
||||
- email
|
||||
- webhook
|
||||
- pagerduty
|
||||
- opsgenie
|
||||
- msteams
|
||||
- googlechat
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
required:
|
||||
- webhookUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelOpsgenieConfig:
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
apiUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
source:
|
||||
type: string
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelPagerdutyConfig:
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
client:
|
||||
type: string
|
||||
clientUrl:
|
||||
type: string
|
||||
component:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
routingKey:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
severity:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- routingKey
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
apiUrl:
|
||||
type: string
|
||||
channel:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- apiUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
url:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
AlertmanagertypesDeprecatedGettableAlert:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -427,30 +54,6 @@ components:
|
||||
- rule
|
||||
- policy
|
||||
type: string
|
||||
AlertmanagertypesGettableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- displayName
|
||||
- config
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesGettableRoutePolicy:
|
||||
properties:
|
||||
channels:
|
||||
@@ -753,19 +356,6 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
AlertmanagertypesPostableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
displayName:
|
||||
type: string
|
||||
generateName:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AlertmanagertypesPostablePlannedMaintenance:
|
||||
properties:
|
||||
alertIds:
|
||||
@@ -19719,69 +19309,6 @@ paths:
|
||||
summary: Get metrics treemap
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/notification_channels:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a notification channel
|
||||
operationId: CreateNotificationChannel
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesPostableNotificationChannel'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:create
|
||||
- tokenizer:
|
||||
- notification-channel:create
|
||||
summary: Create notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -54,93 +54,50 @@ The `fieldContexts` map includes aliases (`tag` -> `attribute`, `spanfield` -> `
|
||||
|
||||
## The Abstraction Stack
|
||||
|
||||
The query pipeline has three layers. The generic layer is written one time, in `pkg/querybuilder`. A storage is written one time per signal. The statement builders compose them. Each layer depends only on the layer below it. This layering is intentional and must be preserved.
|
||||
The query pipeline is built from four interfaces that compose vertically. Each layer has a single responsibility. Each layer depends only on the layers below it. This layering is intentional and must be preserved.
|
||||
|
||||
```
|
||||
StatementBuilder <- Composes one query into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions through the generic layer
|
||||
├── filter visitor <- Parses the filter expression and compiles it one term at a time
|
||||
└── querybuilder (generic) <- Resolution, the condition builder, the column expression builder
|
||||
└── Storage <- What one signal's tables can answer about one field key
|
||||
StatementBuilder <- Orchestrates everything into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions (maps field refs to columns)
|
||||
├── ConditionBuilder <- Builds WHERE predicates (field + operator + value -> SQL)
|
||||
└── FieldMapper <- Maps TelemetryFieldKey -> ClickHouse column expression
|
||||
```
|
||||
|
||||
### Storage
|
||||
### FieldMapper
|
||||
|
||||
**Contract:** `qbtypes.Storage` in `pkg/types/querybuildertypes/querybuildertypesv5/qb.go`. One implementation per signal: traces, logs, metrics, audit, rule state history, the resource fingerprint sub-query, and the related-values metadata.
|
||||
**Contract:** Given a `TelemetryFieldKey`, return a ClickHouse column expression that yields the value for that field when used in a SELECT.
|
||||
|
||||
A storage answers four questions and nothing else:
|
||||
**Principle:** This is the *only* place where field-to-column translation happens. No other layer should contain knowledge of how fields map to storage. If you need a column expression, go through the FieldMapper.
|
||||
|
||||
- `Read(key)`: one call for one field key. It returns the bare SQL read (no alias, no guard, no cast), the membership test (`Presence`, arg-free so it can sit inside guards) and its negation in the storage's own form (`Absence`), what a row without the key reads (`Absent`, below), whether the stage cast must leave the native type alone (`KeepType`: time columns, metrics labels), and whether the key filters but cannot be selected (`FilterOnly`: the legacy string body). It honors the materialization and the evolutions the key carries.
|
||||
- `Fallback(key, operator, value)`: the field keys that could hold a key metadata does not report: column aliases, the type variants of a map read, body paths, and virtual keys that compile to structural predicates (a span search scope, a full-text search over a scope).
|
||||
- `Traits()`: the storage's part in the resource fingerprint split, whether it supports body functions, what it does with an unknown key, and which contexts mean "this signal's own record".
|
||||
- `Compile`: the one override, for a storage with its own condition language (the body JSON language in logs, the index hints of the resource fingerprint, the polarity form of the related values, the String-typed labels of metrics). Every other storage returns `querybuilder.SharedCondition`.
|
||||
**Why:** The user says `http.request.method`. ClickHouse might store it as `attributes_string['http.request.method']`, or as a materialized column `` `attribute_string_http$$request$$method` ``, or via a JSON access path in a body column. This variation is entirely contained within the FieldMapper. Everything above it is storage-agnostic.
|
||||
|
||||
**Principle:** A storage describes its field keys. It never decides a guard, an ambiguity, a warning, or the shape of a fold. Those decisions are derived one time, in the generic layer, from those descriptions.
|
||||
### ConditionBuilder
|
||||
|
||||
### Absent
|
||||
**Contract:** Given a field key, an operator, and a value, produce a valid SQL predicate for a WHERE clause.
|
||||
|
||||
`Read` returns the field's `Absent`: what a row without the field reads. It is a property of the read, not of the column. `resource.x::String` reads the empty string for an absent row, the multi-era fold `multiIf(..., NULL)` reads NULL, and a table column always reads a real value. Every guard derives from it:
|
||||
**Dependency:** Uses FieldMapper for the left-hand side of the condition.
|
||||
|
||||
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|
||||
|---|---|---|---|---|---|
|
||||
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
|
||||
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
|
||||
**Principle:** The ConditionBuilder owns all the complexity of operator semantics, i.e type casting, array operators (`hasAny`/`hasAll` vs `=`), existence checks, and negative operator behavior. This complexity must not leak upward into the StatementBuilder.
|
||||
|
||||
### The generic layer
|
||||
### AggExprRewriter
|
||||
|
||||
`pkg/querybuilder` needs two inputs, made one time per request:
|
||||
**Contract:** Given a user-facing aggregation expression like `sum(duration_nano)`, resolve field references within it and produce valid ClickHouse SQL.
|
||||
|
||||
- The metadata keys: `keys := metadataStore.GetKeysMulti(...)`, the field keys the metadata store reports for the query's names, as `map[name][]*TelemetryFieldKey`.
|
||||
- `q := querybuilder.NewQueryInfo(ctx, orgID, fl, signal, metric, startNs, endNs)`: the time range every read needs, the signal and the queried metric that family admission needs, and the query-path flags (`FamiliesOn`, `BodyJSONOn`), evaluated one time.
|
||||
**Dependency:** Uses FieldMapper to resolve field names within expressions.
|
||||
|
||||
The functions, from the outside in:
|
||||
**Principle:** Aggregation expressions are user-authored strings that contain field references. The rewriter parses them, identifies field references, resolves each through the FieldMapper, and reassembles the expression.
|
||||
|
||||
| Function | Does |
|
||||
|---|---|
|
||||
| `PrepareWhereClause(query, opts)` | The filter visitor. Parses the filter grammar and compiles each term through `RejectsBodyFunction`, `Resolve`, and `Condition`. Returns the WHERE clause, the warnings, and the cost-guard flag. |
|
||||
| `NewAggExprRewriter(settings, fullTextColumn, storage, fl, signal)` | Parses an aggregation expression such as `sum(duration_nano)` and resolves each field reference through `ResolveColumn`. |
|
||||
| `ResolveColumn(ctx, q, storage, key, target, metadata)` | `Resolve` with `FilterOperatorUnknown`, then `Column`. The column stages (raw select, order by, group by, aggregation arguments) call it. |
|
||||
| `Resolve(ctx, q, storage, key, operator, value, metadata)` | One requested key to its meanings in this storage (see "A resolved key"). |
|
||||
| `Condition(ctx, q, storage, resolved, dropResourceFields, operator, value, sb)` | A resolved key to the conditions of one filter term: the split narrows the fields, and each field compiles through `storage.Compile`. |
|
||||
| `Conditions(...)` | `RejectsBodyFunction`, `Resolve`, and `Condition` in one call, for callers outside the visitor: the related-values metadata, the scoped traces predicate resolver, tests. |
|
||||
| `Column(ctx, q, storage, resolved, target)` | A resolved key to one bare column expression. The caller aliases. |
|
||||
| `RejectsBodyFunction(traits, operator)` | Before resolution: a storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors; the fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split could drop it. |
|
||||
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
|
||||
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
|
||||
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads current-first (`COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers), ORs the member presence tests, and reads for a row without any member what the merge's tail reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
|
||||
### StatementBuilder
|
||||
|
||||
### A resolved key
|
||||
**Contract:** Given a complete `QueryBuilderQuery`, a time range, and a request type, produces an executable SQL statement.
|
||||
|
||||
`Resolve` turns one requested key into a `Resolved` value. It is the only thing the condition builder and the column expression builder receive. Compile it with the operator and value it was resolved with: the stage is the operand, and a nil value means a column or presence use.
|
||||
**Dependency:** Uses all three abstractions above.
|
||||
|
||||
```go
|
||||
type Resolved struct {
|
||||
Key *TelemetryFieldKey // the spelling the request used
|
||||
Fields []*LogicalField // its meanings in this storage, one per interpretation
|
||||
FromFallback bool // the fields came from the storage's Fallback, not from metadata matches
|
||||
Ambiguous bool // the matches held several interpretations
|
||||
Skipped bool // the storage contributes nothing for this key
|
||||
Warnings []string // the warnings to surface: ambiguity, not-found
|
||||
}
|
||||
```
|
||||
|
||||
A `LogicalField` is one meaning: one name, context, and data type, backed by one or more physical members. A family (one field with several spellings) is one logical field with several members, current spelling first. Ambiguity (one name, different fields) is several logical fields.
|
||||
|
||||
The resolution order is the same for every storage and every stage:
|
||||
|
||||
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) matches its own context first. Only when nothing matches does it look up as if it had no context, so `span.http.method` corrects to the attribute. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are honored as written.
|
||||
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
|
||||
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A column stage keeps every interpretation in metadata order and folds them, so a select or a group by sees the value wherever it is.
|
||||
4. **Intrinsic column first**, bare keys only. A column every row has leads the list, whether metadata reports it or the storage's `Fallback` does. A `Fallback` column key carries the data type its column reads as (`querybuilder.ColumnDataType`), and a same-named metadata key of a contradicting type drops; a time column has no field data type and merges none. A metadata gap degrades to the correct column, never to a corrupt metadata key.
|
||||
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.
|
||||
|
||||
The condition builder then applies the fingerprint split (`MainOfSplit` drops the resource fields the sub-query serves and keeps fallback keys; `FingerprintOfSplit` keeps resource fields only), compiles each field, and the visitor joins the per-field conditions by the operator's polarity. The column expression builder reads each field through `LogicalRead`, casts for the coerced stages unless the read keeps its type, guards by `Absent`, and renders one candidate bare or several as `multiIf(..., NULL)`. A filter-only candidate drops; the error surfaces only when none remains.
|
||||
**Principle:** This is the composition layer. It does not contain field mapping logic, condition building logic, or expression rewriting logic. It orchestrates the other abstractions. If you find storage-specific logic creeping into the StatementBuilder, push it down into the appropriate abstraction.
|
||||
|
||||
### Invariant: No layer skipping
|
||||
|
||||
A statement builder must not spell a column or a condition. It calls `ResolveColumn` and the filter visitor. A storage must not decide a guard or an ambiguity. It declares `Absent` and answers the four questions. Skipping layers recreates the per-signal copies the contract removed.
|
||||
The StatementBuilder must not call FieldMapper directly to build conditions, it goes through the ConditionBuilder. The AggExprRewriter must not hardcode column names, it goes through the FieldMapper. Skipping layers creates hidden coupling and makes the system fragile to storage changes.
|
||||
|
||||
---
|
||||
|
||||
@@ -162,15 +119,14 @@ Only additive/counting aggregations (`count`, `count_distinct`, `sum`, `rate`) d
|
||||
|
||||
**Enforcement:** `GetQueriesSupportingZeroDefault` determines which queries can default to zero. The `FormulaEvaluator` consumes this via `canDefaultZero`. Changes to aggregation handling must preserve this distinction.
|
||||
|
||||
### Constraint: The exists guard derives from the operator and from the field
|
||||
### Constraint: Existence semantics differ for positive vs negative operators
|
||||
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence for a field that reads a sentinel when absent. `http.method = GET` on a map attribute means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) never add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
- A field that reads NULL when absent, and a table column, take no guard on any operator: the comparison already excludes the absent row, or there is no absent row.
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence. `http.method = GET` means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) do **not** add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. The operator side is declared in `AddDefaultExistsFilter`; the field side is the `Absent` a storage returns from `Exists`.
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. This is documented in `AddDefaultExistsFilter`.
|
||||
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Any new read must declare what an absent row reads. Never add a guard by hand in a storage.
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Do not add operators without considering this.
|
||||
|
||||
### Constraint: Post-processing functions operate on result sets, not in SQL
|
||||
|
||||
@@ -232,11 +188,11 @@ The `MetadataStore` interface provides runtime field discovery and type resoluti
|
||||
|
||||
The same name can map to multiple `TelemetryFieldKey` variants (different contexts, different types). The metadata store returns *all* variants. Resolution to a single field happens during query building, using the query's signal and any explicit context/type hints from the user.
|
||||
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field. `querybuilder.Resolve` is where the variants settle: it returns every interpretation as a `LogicalField`, marks the result `Ambiguous`, and carries the warning.
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field.
|
||||
|
||||
### Principle: Materialized fields are a performance optimization, not a semantic distinction
|
||||
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the storage's `Read` to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the FieldMapper to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
|
||||
### Principle: JSON body fields require access plans
|
||||
|
||||
@@ -247,14 +203,14 @@ Fields inside JSON body columns (`body.response.errors[].code`) need pre-compute
|
||||
## Summary of Inviolable Rules
|
||||
|
||||
1. **User-facing types never contain ClickHouse column names or SQL fragments.**
|
||||
2. **Field-to-column translation only happens in a Storage (`Read`, `Fallback`).**
|
||||
2. **Field-to-column translation only happens in FieldMapper.**
|
||||
3. **Normalization happens once at the API boundary, never deeper.**
|
||||
4. **Historical aliases in fieldContexts and fieldDataTypes must not be removed.**
|
||||
5. **Formula evaluation stays in Go — do not push it into ClickHouse JOINs.**
|
||||
6. **Zero-defaulting is aggregation-type-dependent — do not universally default to zero.**
|
||||
7. **The exists guard derives from `AddDefaultExistsFilter` and `Absent`; positive operators guard sentinel reads, negative operators never guard.**
|
||||
7. **Positive operators imply existence, negative operators do not.**
|
||||
8. **Post-processing functions operate on Go result sets, not in SQL.**
|
||||
9. **All user-facing types reject unknown JSON fields with suggestions.**
|
||||
10. **Validation rules are gated by request type.**
|
||||
11. **Query names must be unique within a composite query.**
|
||||
12. **The three-layer abstraction stack (Storage -> querybuilder generic layer -> StatementBuilder) must not be bypassed or flattened. A storage describes its field keys; the generic layer decides.**
|
||||
12. **The four-layer abstraction stack (FieldMapper -> ConditionBuilder -> AggExprRewriter -> StatementBuilder) must not be bypassed or flattened.**
|
||||
|
||||
85
ee/licensing/httplicensing/api.go
Normal file
85
ee/licensing/httplicensing/api.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package httplicensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type licensingAPI struct {
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
|
||||
return &licensingAPI{licensing: licensing}
|
||||
}
|
||||
|
||||
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
req := new(licensetypes.PostableSubscription)
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, gettableSubscription)
|
||||
}
|
||||
|
||||
func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
req := new(licensetypes.PostableSubscription)
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettableSubscription, err := api.licensing.Portal(ctx, orgID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, gettableSubscription)
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package httplicensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -225,6 +228,47 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
|
||||
activeLicense, err := provider.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(postableSubscription)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal checkout payload")
|
||||
}
|
||||
|
||||
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
|
||||
activeLicense, err := provider.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(postableSubscription)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal portal payload")
|
||||
}
|
||||
|
||||
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error) {
|
||||
license, err := provider.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
@@ -41,6 +42,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
Signoz: signoz,
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
}, config)
|
||||
@@ -70,6 +72,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
|
||||
|
||||
// v4
|
||||
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
|
||||
|
||||
|
||||
76
ee/query-service/app/api/license.go
Normal file
76
ee/query-service/app/api/license.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/model"
|
||||
)
|
||||
|
||||
type DayWiseBreakdown struct {
|
||||
Type string `json:"type"`
|
||||
Breakdown []DayWiseData `json:"breakdown"`
|
||||
}
|
||||
|
||||
type DayWiseData struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Count float64 `json:"count"`
|
||||
Size float64 `json:"size"`
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
type tierBreakdown struct {
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
TierStart int64 `json:"tierStart"`
|
||||
TierEnd int64 `json:"tierEnd"`
|
||||
TierCost float64 `json:"tierCost"`
|
||||
}
|
||||
|
||||
type usageResponse struct {
|
||||
Type string `json:"type"`
|
||||
Unit string `json:"unit"`
|
||||
Tiers []tierBreakdown `json:"tiers"`
|
||||
DayWiseBreakdown DayWiseBreakdown `json:"dayWiseBreakdown"`
|
||||
}
|
||||
|
||||
type details struct {
|
||||
Total float64 `json:"total"`
|
||||
Breakdown []usageResponse `json:"breakdown"`
|
||||
BaseFee float64 `json:"baseFee"`
|
||||
BillTotal float64 `json:"billTotal"`
|
||||
}
|
||||
|
||||
type billingData struct {
|
||||
BillingPeriodStart int64 `json:"billingPeriodStart"`
|
||||
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
|
||||
Details details `json:"details"`
|
||||
Discount float64 `json:"discount"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
|
||||
licenseKey := r.URL.Query().Get("licenseKey")
|
||||
|
||||
if licenseKey == "" {
|
||||
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var billing billingData
|
||||
if err := json.Unmarshal(data, &billing); err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
ah.Respond(w, billing)
|
||||
}
|
||||
@@ -184,6 +184,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
@@ -196,7 +197,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
@@ -169,12 +169,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
// Check for workspace blocked (trial expired)
|
||||
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
(isAdmin &&
|
||||
(pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.MY_SETTINGS));
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
|
||||
if (
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
|
||||
@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
|
||||
assertStaysOnRoute(ROUTES.MY_SETTINGS);
|
||||
});
|
||||
|
||||
it('should allow VIEWER to access /settings when workspace is blocked', () => {
|
||||
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.SETTINGS,
|
||||
appContext: {
|
||||
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
|
||||
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.BILLING,
|
||||
appContext: {
|
||||
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked when trying to access org-settings', async () => {
|
||||
@@ -819,7 +819,7 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should allow EDITOR to access /settings when workspace is blocked', () => {
|
||||
it('should redirect EDITOR to workspace locked when trying to access settings', async () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.SETTINGS,
|
||||
appContext: {
|
||||
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should not redirect when already on workspace locked page', () => {
|
||||
@@ -1626,7 +1626,6 @@ describe('PrivateRoute', () => {
|
||||
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
BILLING: { path: ROUTES.BILLING, deniedRoles: DENIED_ROLES },
|
||||
};
|
||||
|
||||
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
|
||||
|
||||
59
frontend/src/api/billing/getUsage.ts
Normal file
59
frontend/src/api/billing/getUsage.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface DayBreakdownEntry {
|
||||
timestamp: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TierEntry {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
tierCost: number;
|
||||
}
|
||||
|
||||
export interface BreakdownEntry {
|
||||
type: string;
|
||||
unit: string;
|
||||
dayWiseBreakdown: {
|
||||
breakdown: DayBreakdownEntry[];
|
||||
};
|
||||
tiers?: TierEntry[];
|
||||
}
|
||||
|
||||
export interface UsageResponsePayloadProps {
|
||||
billingPeriodStart: number;
|
||||
billingPeriodEnd: number;
|
||||
details: {
|
||||
total: number;
|
||||
baseFee: number;
|
||||
breakdown: BreakdownEntry[];
|
||||
billTotal: number;
|
||||
};
|
||||
discount: number;
|
||||
subscriptionStatus?: string;
|
||||
}
|
||||
|
||||
const getUsage = async (
|
||||
licenseKey: string,
|
||||
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getUsage;
|
||||
@@ -19,10 +19,8 @@ import type {
|
||||
|
||||
import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
CreateChannel201,
|
||||
CreateNotificationChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
GetChannelByID200,
|
||||
GetChannelByIDPathParameters,
|
||||
@@ -649,87 +647,3 @@ export const useTestChannelDeprecated = <
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a notification channel
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const createNotificationChannel = (
|
||||
alertmanagertypesPostableNotificationChannelDTO?: BodyType<AlertmanagertypesPostableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateNotificationChannel201>({
|
||||
url: `/api/v2/notification_channels`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesPostableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createNotificationChannel(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>
|
||||
>;
|
||||
export type CreateNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesPostableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type CreateNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const useCreateNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -37,476 +37,6 @@ export interface AlertmanagertypesChannelDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
|
||||
slack = 'slack',
|
||||
}
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiUrl: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
channel?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @enum slack
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelSlackConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
html?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @enum email
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelEmailConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind {
|
||||
webhook = 'webhook',
|
||||
}
|
||||
export interface AlertmanagertypesChannelWebhookConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
bearerToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO {
|
||||
/**
|
||||
* @enum webhook
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelWebhookConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
client?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
component?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
routingKey: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
severity?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @enum pagerduty
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelPagerdutyConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @enum opsgenie
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelOpsgenieConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind {
|
||||
msteams = 'msteams',
|
||||
}
|
||||
export interface AlertmanagertypesChannelMSTeamsConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO {
|
||||
/**
|
||||
* @enum msteams
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelMSTeamsConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind {
|
||||
googlechat = 'googlechat',
|
||||
}
|
||||
export interface AlertmanagertypesChannelGoogleChatConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO {
|
||||
/**
|
||||
* @enum googlechat
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelGoogleChatConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopenDuration?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopenTransition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolveTransition?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wontFixResolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @enum jira
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelJiraConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind {
|
||||
jsmops = 'jsmops',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO {
|
||||
/**
|
||||
* @enum jsmops
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelJSMOpsConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @enum incidentio
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelConfigDTO =
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
|
||||
export enum AlertmanagertypesChannelKindDTO {
|
||||
slack = 'slack',
|
||||
email = 'email',
|
||||
webhook = 'webhook',
|
||||
pagerduty = 'pagerduty',
|
||||
opsgenie = 'opsgenie',
|
||||
msteams = 'msteams',
|
||||
googlechat = 'googlechat',
|
||||
jira = 'jira',
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -558,32 +88,6 @@ export enum AlertmanagertypesExpressionKindDTO {
|
||||
rule = 'rule',
|
||||
policy = 'policy',
|
||||
}
|
||||
export interface AlertmanagertypesGettableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesGettableRoutePolicyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -2244,22 +1748,6 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesPostableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
generateName?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -13162,14 +12650,6 @@ export type GetMetricsTreemap200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationChannel201 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
25
frontend/src/api/quickFilters/getCustomFilters.ts
Normal file
25
frontend/src/api/quickFilters/getCustomFilters.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
const getCustomFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
const { signal } = props;
|
||||
try {
|
||||
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getCustomFilters;
|
||||
13
frontend/src/api/quickFilters/updateCustomFilters.ts
Normal file
13
frontend/src/api/quickFilters/updateCustomFilters.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import axios from 'api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
|
||||
|
||||
const updateCustomFiltersAPI = async (
|
||||
props: UpdateCustomFiltersProps,
|
||||
): Promise<SuccessResponse<void> | AxiosError> =>
|
||||
axios.put(`/orgs/me/filters`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
export default updateCustomFiltersAPI;
|
||||
28
frontend/src/api/v1/checkout/create.ts
Normal file
28
frontend/src/api/v1/checkout/create.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
CheckoutRequestPayloadProps,
|
||||
CheckoutSuccessPayloadProps,
|
||||
PayloadProps,
|
||||
} from 'types/api/billing/checkout';
|
||||
|
||||
const updateCreditCardApi = async (
|
||||
props: CheckoutRequestPayloadProps,
|
||||
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/checkout', {
|
||||
url: props.url,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateCreditCardApi;
|
||||
28
frontend/src/api/v1/portal/create.ts
Normal file
28
frontend/src/api/v1/portal/create.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
CheckoutRequestPayloadProps,
|
||||
CheckoutSuccessPayloadProps,
|
||||
PayloadProps,
|
||||
} from 'types/api/billing/checkout';
|
||||
|
||||
const manageCreditCardApi = async (
|
||||
props: CheckoutRequestPayloadProps,
|
||||
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/portal', {
|
||||
url: props.url,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default manageCreditCardApi;
|
||||
@@ -2,10 +2,8 @@ import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
RawData,
|
||||
ScalarData,
|
||||
@@ -13,11 +11,6 @@ import {
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: QueryEnvelope,
|
||||
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
|
||||
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
|
||||
|
||||
function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
@@ -416,19 +409,21 @@ export function convertV5ResponseToLegacy(
|
||||
const v5Data = payload?.data;
|
||||
|
||||
const aggregationPerQuery =
|
||||
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
isBuilderQueryEnvelope(query) &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
params?.compositeQuery?.queries
|
||||
?.filter((query) => query.type === 'builder_query')
|
||||
.reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
query.type === 'builder_query' &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
|
||||
// clickhouse_sql queries have no aggregation metadata; their value columns
|
||||
// are named/keyed by the real SQL alias the response carries (see getColId).
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
QueryBuilderFormula as V5QueryBuilderFormula,
|
||||
QueryEnvelope,
|
||||
QueryRangePayloadV5,
|
||||
RequestType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -936,41 +935,3 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 builder query type', () => {
|
||||
const buildEnvelope = (
|
||||
builderQueryType: IBuilderQuery['builderQueryType'],
|
||||
requestType: RequestType,
|
||||
): QueryEnvelope => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
{
|
||||
A: {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
builderQueryType,
|
||||
} as unknown as IBuilderQuery,
|
||||
},
|
||||
requestType,
|
||||
);
|
||||
return envelope;
|
||||
};
|
||||
|
||||
it.each<[RequestType]>([
|
||||
['trace'],
|
||||
['raw'],
|
||||
['time_series'],
|
||||
['scalar'],
|
||||
['distribution'],
|
||||
])('sends builder_ai_query for the %s request type', (requestType) => {
|
||||
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, IBuilderQuery['builderQueryType']]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('sends builder_query for %s', (_label, builderQueryType) => {
|
||||
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
|
||||
}
|
||||
|
||||
return {
|
||||
type: queryData.builderQueryType ?? 'builder_query',
|
||||
type: 'builder_query' as QueryType,
|
||||
spec,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
@@ -19,7 +18,9 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -37,7 +38,7 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
updateCreditCardApi,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
@@ -93,23 +94,18 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
<Button
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
Add Credit Card
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
|
||||
@@ -4,17 +4,16 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
@@ -119,7 +118,9 @@ function LaunchChatSupport({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -137,7 +138,7 @@ function LaunchChatSupport({
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
updateCreditCardApi,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
@@ -192,23 +193,18 @@ function LaunchChatSupport({
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
<Button
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
Add Credit Card
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
|
||||
@@ -16,6 +16,8 @@ import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
@@ -52,12 +54,6 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
SuggestedFieldKey,
|
||||
SuggestedFieldKeysByName,
|
||||
} from './fieldSuggestions';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -265,8 +261,10 @@ function QuerySearch({
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
|
||||
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
|
||||
const generateOptions = (keys: {
|
||||
[key: string]: QueryKeyDataSuggestionsProps[];
|
||||
}): any[] =>
|
||||
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
|
||||
items.map(({ name, fieldDataType, fieldContext }) => ({
|
||||
label: name,
|
||||
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
|
||||
@@ -319,9 +317,8 @@ function QuerySearch({
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
@@ -363,7 +360,6 @@ function QuerySearch({
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
metricNamespace,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -497,11 +493,10 @@ function QuerySearch({
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await fetchFieldValuesForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
: await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
@@ -606,7 +601,6 @@ function QuerySearch({
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
} from '../fieldSuggestions';
|
||||
|
||||
jest.mock('api/generated/services/ai-observability', () => ({
|
||||
getAIObservabilityFieldsKeys: jest.fn(),
|
||||
getAIObservabilityFieldsValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsKeys
|
||||
>;
|
||||
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsValues
|
||||
>;
|
||||
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
const aiValuesResponse = (
|
||||
values: { stringValues?: string[]; numberValues?: number[] } | null,
|
||||
complete = true,
|
||||
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
|
||||
({
|
||||
status: 'success',
|
||||
data: { complete, values },
|
||||
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
|
||||
|
||||
describe('fetchFieldKeysForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const keys = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'llm',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
|
||||
expect(mockedGenericKeys).not.toHaveBeenCalled();
|
||||
expect(keys.data.data).toStrictEqual({
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
mockedGenericKeys.mockResolvedValue({
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as Awaited<ReturnType<typeof getKeySuggestions>>);
|
||||
|
||||
await fetchFieldKeysForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'svc',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).not.toHaveBeenCalled();
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a null ai_observability keys payload to an empty map', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
|
||||
});
|
||||
|
||||
it('passes the generic response through untouched', async () => {
|
||||
const genericResponse = {
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
|
||||
mockedGenericKeys.mockResolvedValue(genericResponse);
|
||||
|
||||
await expect(
|
||||
fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFieldValuesForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIValues.mockResolvedValue(
|
||||
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
|
||||
);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'gen_ai.request.model',
|
||||
searchText: 'gpt',
|
||||
});
|
||||
|
||||
expect(mockedGenericValues).not.toHaveBeenCalled();
|
||||
expect(response).toStrictEqual({
|
||||
data: {
|
||||
data: {
|
||||
complete: true,
|
||||
values: { stringValues: ['gpt-4o'], numberValues: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the key as the name the endpoint expects', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
|
||||
|
||||
await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).toHaveBeenCalledWith({
|
||||
name: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
|
||||
|
||||
await expect(
|
||||
fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'llm_call_count',
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
data: { data: { complete: false, values: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
const genericResponse = {
|
||||
data: {
|
||||
data: { complete: false, values: { stringValues: ['frontend'] } },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
|
||||
mockedGenericValues.mockResolvedValue(genericResponse);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).not.toHaveBeenCalled();
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
}),
|
||||
);
|
||||
expect(response).toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export interface SuggestedFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
|
||||
|
||||
export interface SuggestedFieldKeysPayload {
|
||||
complete: boolean;
|
||||
keys: SuggestedFieldKeysByName;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldKeysResponse {
|
||||
data: { data?: SuggestedFieldKeysPayload };
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesPayload {
|
||||
complete?: boolean;
|
||||
values?: {
|
||||
stringValues?: string[] | null;
|
||||
numberValues?: number[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesResponse {
|
||||
data: { data?: SuggestedFieldValuesPayload };
|
||||
}
|
||||
|
||||
interface FetchFieldKeysParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
metricNamespace?: string;
|
||||
}
|
||||
|
||||
interface FetchFieldValuesParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
key: string;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
}
|
||||
|
||||
export const fetchFieldKeysForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsKeys({ searchText });
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: response.data
|
||||
? { complete: response.data.complete, keys: response.data.keys ?? {} }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchFieldValuesForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsValues({
|
||||
name: key,
|
||||
searchText,
|
||||
});
|
||||
|
||||
return { data: { data: response.data } };
|
||||
}
|
||||
|
||||
// getValueSuggestions' declared response type does not match what the endpoint returns.
|
||||
return getValueSuggestions({
|
||||
signal: dataSource,
|
||||
key,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
}) as unknown as Promise<SuggestedFieldValuesResponse>;
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
const { dataSource } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -94,9 +94,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
);
|
||||
|
||||
const showSpanScopeSelector = useMemo(
|
||||
() =>
|
||||
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
|
||||
[dataSource, builderQueryType],
|
||||
() => dataSource === DataSource.TRACES,
|
||||
[dataSource],
|
||||
);
|
||||
|
||||
const showInlineQuerySearch = useMemo(() => {
|
||||
|
||||
@@ -22,10 +22,7 @@ interface UseFieldValuesReturn {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export const DATA_SOURCE_TO_SIGNAL: Record<
|
||||
DataSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
function SortableFilter({
|
||||
filter,
|
||||
@@ -25,13 +25,13 @@ function SortableFilter({
|
||||
allowDrag,
|
||||
allowRemove,
|
||||
}: {
|
||||
filter: TelemetryFieldKey;
|
||||
onRemove: (filter: TelemetryFieldKey) => void;
|
||||
filter: FilterType;
|
||||
onRemove: (filter: FilterType) => void;
|
||||
allowDrag: boolean;
|
||||
allowRemove: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: filter.key as string });
|
||||
useSortable({ id: filter.key });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -46,14 +46,14 @@ function SortableFilter({
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={16} />}
|
||||
{filter.name}
|
||||
{filter.key}
|
||||
</div>
|
||||
{allowRemove && (
|
||||
<Button
|
||||
className="remove-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
onRemove(filter);
|
||||
onRemove(filter as FilterType);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -69,8 +69,8 @@ function AddedFilters({
|
||||
setAddedFilters,
|
||||
}: {
|
||||
inputValue: string;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
}): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
@@ -90,12 +90,12 @@ function AddedFilters({
|
||||
const filteredAddedFilters = useMemo(
|
||||
() =>
|
||||
addedFilters.filter((filter) =>
|
||||
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
),
|
||||
[addedFilters, inputValue],
|
||||
);
|
||||
|
||||
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
|
||||
const handleRemoveFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ function AddedFilters({
|
||||
<div className="no-values-found">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={addedFilters.map((f) => f.key as string)}
|
||||
items={addedFilters.map((f) => f.key)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -37,48 +37,106 @@ function OtherFilters({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
inputValue: string;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
}): JSX.Element {
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
},
|
||||
{ query: { enabled: !!signal } },
|
||||
const isLogDataSource = useMemo(
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
);
|
||||
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal as TelemetryFieldKey['signal'],
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
}));
|
||||
|
||||
const addedKeys = new Set(
|
||||
addedFilters.map((filter) =>
|
||||
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
|
||||
),
|
||||
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
|
||||
useGetAttributeSuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
filters: {} as TagFilter,
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isLogDataSource,
|
||||
},
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
|
||||
useGetAggregateKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
|
||||
useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFilters = useMemo(() => {
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
}) as BaseAutocompleteData,
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: filter.key,
|
||||
dataType: filter.dataType,
|
||||
type: filter.type,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
if (isFetching) {
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) {
|
||||
return <OtherFiltersSkeleton />;
|
||||
}
|
||||
if (!otherFilters?.length) {
|
||||
@@ -87,11 +145,11 @@ function OtherFilters({
|
||||
|
||||
return otherFilters.map((filter) => (
|
||||
<div key={filter.key} className="qf-filter-item other-filters-item">
|
||||
<div className="qf-filter-key">{filter.name}</div>
|
||||
<div className="qf-filter-key">{filter.key}</div>
|
||||
<Button
|
||||
className="add-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => handleAddFilter(filter)}
|
||||
onClick={(): void => handleAddFilter(filter as FilterType)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
@@ -18,7 +19,7 @@ function QuickFiltersSettings({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: TelemetryFieldKey[];
|
||||
customFilters: FilterType[];
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -27,7 +28,6 @@ function QuickFiltersSettings({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
@@ -39,6 +39,18 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
// check if both arrays have the same length and same order of elements
|
||||
!(
|
||||
addedFilters.length === customFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === customFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, customFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="qf-header">
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: TelemetryFieldKey[];
|
||||
customFilters: FilterType[];
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
handleSettingsClose: () => void;
|
||||
handleDiscardChanges: () => void;
|
||||
handleSaveChanges: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
isUpdatingCustomFilters: boolean;
|
||||
inputValue: string;
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -41,43 +37,27 @@ const useQuickFilterSettings = ({
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
customFilters.map((filter) => ({
|
||||
...filter,
|
||||
key: buildCompositeKey(
|
||||
filter.name,
|
||||
filter.fieldContext,
|
||||
filter.fieldDataType,
|
||||
),
|
||||
})),
|
||||
[customFilters],
|
||||
);
|
||||
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
|
||||
normalizedCustomFilters,
|
||||
);
|
||||
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
|
||||
useUpdateQuickFilters({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
void logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: error.message || SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
notifications.error({
|
||||
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
});
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
@@ -98,32 +78,19 @@ const useQuickFilterSettings = ({
|
||||
}, [setIsSettingsOpen]);
|
||||
|
||||
const handleDiscardChanges = useCallback((): void => {
|
||||
setAddedFilters(normalizedCustomFilters);
|
||||
}, [normalizedCustomFilters, setAddedFilters]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
addedFilters.length === normalizedCustomFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === normalizedCustomFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, normalizedCustomFilters],
|
||||
);
|
||||
setAddedFilters(customFilters);
|
||||
}, [customFilters, setAddedFilters]);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: signal },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
key: filter.key,
|
||||
datatype: filter.dataType,
|
||||
type: filter.type,
|
||||
})),
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -135,7 +102,6 @@ const useQuickFilterSettings = ({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { useQuery } from 'react-query';
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@@ -11,7 +13,7 @@ interface UseFilterConfigProps {
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
customFilters: FilterType[];
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -23,16 +25,17 @@ const useFilterConfig = ({
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
isFetching: isCustomFiltersLoading,
|
||||
data,
|
||||
data: customFilters = [],
|
||||
refetch,
|
||||
} = useGetQuickFilters(
|
||||
{ source: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
|
||||
[data],
|
||||
} = useQuery<FilterType[], Error>(
|
||||
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
||||
async () => {
|
||||
const res = await getCustomFilters({ signal: signal || '' });
|
||||
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
|
||||
},
|
||||
{
|
||||
enabled: !!signal,
|
||||
},
|
||||
);
|
||||
|
||||
const isDynamicFilters = useMemo(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -338,63 +338,6 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different context as distinct entries', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
level: [
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
// Both `level` variants are shown despite sharing a name.
|
||||
await waitFor(() =>
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Adding one variant removes only that one; the other stays.
|
||||
const firstLevel = within(otherSection).getAllByText('level')[0];
|
||||
const addButton = firstLevel.parentElement?.querySelector('button');
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -513,10 +456,12 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
const requestBody = putHandler.mock.calls[0][0];
|
||||
expect(requestBody.filters).not.toContainEqual(
|
||||
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
|
||||
expect(requestBody.filters).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
|
||||
]),
|
||||
);
|
||||
expect(requestBody.filters).toHaveLength(10);
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
});
|
||||
|
||||
it('should render duration slider for duration_nono filter', async () => {
|
||||
@@ -667,9 +612,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
filters: [
|
||||
...(quickFiltersListResponse.data.filters ?? []),
|
||||
{
|
||||
name: 'new.custom.filter',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'new.custom.filter',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
|
||||
@@ -14,19 +12,6 @@ const FILTER_TYPE_MAP: Record<string, FiltersType> = {
|
||||
duration_nano: FiltersType.DURATION,
|
||||
};
|
||||
|
||||
// The map below exists only for the old v3 attribute-values fetch
|
||||
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
|
||||
// Once the values fetch moves to fields/values, remove this and reduce
|
||||
// attributeKey to { id, key }.
|
||||
|
||||
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
};
|
||||
|
||||
const mapFieldContext = (fieldContext?: string): string =>
|
||||
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
|
||||
|
||||
const getFilterName = (str: string): string => {
|
||||
if (FILTER_TITLE_MAP[str]) {
|
||||
return FILTER_TITLE_MAP[str];
|
||||
@@ -41,16 +26,16 @@ const getFilterName = (str: string): string => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.name]) {
|
||||
return FILTER_TYPE_MAP[att.name];
|
||||
const getFilterType = (att: FilterType): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.key]) {
|
||||
return FILTER_TYPE_MAP[att.key];
|
||||
}
|
||||
return FiltersType.CHECKBOX;
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
customFilters?: FilterType[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length || !signal) {
|
||||
@@ -61,13 +46,13 @@ export const getFilterConfig = (
|
||||
(att, index) =>
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.name),
|
||||
title: getFilterName(att.key),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
dataType: fieldDataTypeToDataType(att.fieldDataType),
|
||||
type: mapFieldContext(att.fieldContext),
|
||||
id: att.key,
|
||||
key: att.key,
|
||||
dataType: att.dataType,
|
||||
type: att.type,
|
||||
},
|
||||
defaultOpen: index < 2,
|
||||
}) as IQuickFiltersConfig,
|
||||
|
||||
@@ -4,18 +4,14 @@ import { refreshLicense } from 'api/generated/services/licenses';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { RefreshCcw } from '@signozhq/icons';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildLicenseUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
function RefreshPaymentStatus({
|
||||
type,
|
||||
className,
|
||||
withPortal,
|
||||
}: {
|
||||
type?: 'button' | 'text' | 'tooltip';
|
||||
className?: string;
|
||||
withPortal?: false;
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation(['failedPayment']);
|
||||
const { activeLicense, activeLicenseRefetch } = useAppContext();
|
||||
@@ -40,25 +36,17 @@ function RefreshPaymentStatus({
|
||||
};
|
||||
|
||||
const button = (
|
||||
<AuthZTooltip
|
||||
checks={
|
||||
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
|
||||
}
|
||||
enabled={!!activeLicense}
|
||||
withPortal={withPortal}
|
||||
<Button
|
||||
variant="link"
|
||||
color={type === 'text' ? 'none' : 'secondary'}
|
||||
size="md"
|
||||
className={className}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
prefix={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color={type === 'text' ? 'none' : 'secondary'}
|
||||
size="md"
|
||||
className={className}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
prefix={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -74,7 +62,6 @@ function RefreshPaymentStatus({
|
||||
RefreshPaymentStatus.defaultProps = {
|
||||
type: 'button',
|
||||
className: undefined,
|
||||
withPortal: undefined,
|
||||
};
|
||||
|
||||
export default RefreshPaymentStatus;
|
||||
|
||||
@@ -348,19 +348,6 @@ export const initialQueryMeterWithType: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const initialQueryAIWithType: Query = {
|
||||
...initialQueryWithType,
|
||||
builder: {
|
||||
...initialQueryWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
builderQueryType: 'builder_ai_query',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const operatorsByTypes: Record<LocalDataType, string[]> = {
|
||||
string: Object.values(StringOperators),
|
||||
number: Object.values(NumberOperators),
|
||||
|
||||
@@ -15,6 +15,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
|
||||
GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS',
|
||||
DASHBOARD_BY_ID: 'DASHBOARD_BY_ID',
|
||||
GET_BILLING_USAGE: 'GET_BILLING_USAGE',
|
||||
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
|
||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
||||
|
||||
@@ -184,11 +184,6 @@
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-restricted-banner,
|
||||
|
||||
@@ -20,8 +20,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { updateSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
|
||||
@@ -31,8 +30,6 @@ import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
|
||||
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
|
||||
import { Events } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -66,7 +63,8 @@ import {
|
||||
UPDATE_LATEST_VERSION,
|
||||
UPDATE_LATEST_VERSION_ERROR,
|
||||
} from 'types/actions/app';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import {
|
||||
ChangelogSchema,
|
||||
DeploymentType,
|
||||
@@ -79,6 +77,7 @@ import {
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
@@ -167,7 +166,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
return Math.abs(currentDate.diff(userCreationDate, 'day'));
|
||||
}, [user.createdAt]);
|
||||
|
||||
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -185,7 +186,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
};
|
||||
|
||||
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
|
||||
useMutation(updateSubscription, {
|
||||
useMutation(manageCreditCardApi, {
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
@@ -468,8 +469,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [isLoggedIn]);
|
||||
|
||||
const handleUpgrade = useCallback((): void => {
|
||||
history.push(ROUTES.BILLING);
|
||||
}, []);
|
||||
if (user.role === USER_ROLES.ADMIN) {
|
||||
history.push(ROUTES.BILLING);
|
||||
}
|
||||
}, [user.role]);
|
||||
|
||||
const handleFailedPayment = useCallback((): void => {
|
||||
manageCreditCard({
|
||||
@@ -583,21 +586,25 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
<div>
|
||||
Our systems are taking longer than expected for your trial workspace.
|
||||
Please{' '}
|
||||
<span>
|
||||
<a
|
||||
className="upgrade-link"
|
||||
onClick={(): void => {
|
||||
notifications.destroy('slow-api-warning');
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
<a
|
||||
className="upgrade-link"
|
||||
onClick={(): void => {
|
||||
notifications.destroy('slow-api-warning');
|
||||
|
||||
logEvent(`Slow API Banner: Upgrade clicked`, {});
|
||||
logEvent(`Slow API Banner: Upgrade clicked`, {});
|
||||
|
||||
handleUpgrade();
|
||||
}}
|
||||
>
|
||||
upgrade
|
||||
</a>
|
||||
your workspace for a smoother experience.
|
||||
</span>
|
||||
handleUpgrade();
|
||||
}}
|
||||
>
|
||||
upgrade
|
||||
</a>
|
||||
your workspace for a smoother experience.
|
||||
</span>
|
||||
) : (
|
||||
'contact your administrator for upgrading to a paid plan for a smoother experience.'
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
duration: 60000,
|
||||
@@ -787,18 +794,22 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
<div className="trial-expiry-banner">
|
||||
You are in free trial period. Your free trial will end on{' '}
|
||||
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleUpgrade}>
|
||||
upgrade
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
{' '}
|
||||
| Already upgraded? <RefreshPaymentStatus type="text" />
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleUpgrade}>
|
||||
upgrade
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already upgraded? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
'Please contact your administrator for upgrading to a paid plan.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -815,20 +826,22 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<AuthZTooltip checks={SubscriptionManagePermissions}>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleFailedPayment}>
|
||||
pay the bill
|
||||
</a>
|
||||
</AuthZTooltip>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already paid? <RefreshPaymentStatus type="text" />
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already paid? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
' Please contact your administrator to pay the bill.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import {
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionReadPermission,
|
||||
SubscriptionUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { trialConvertedToSubscriptionResponse } from 'mocks-server/__mockdata__/licenses';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import BillingContainer from './BillingContainer';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('BillingContainer - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders usage and enables actions when all subscription permissions are granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<BillingContainer />);
|
||||
|
||||
await expect(
|
||||
screen.findByRole('columnheader', { name: /data ingested/i }),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeEnabled();
|
||||
});
|
||||
expect(screen.queryByText(/not authorized/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks the usage section when subscription read is denied', async () => {
|
||||
server.use(setupAuthzDeny(SubscriptionReadPermission));
|
||||
|
||||
render(<BillingContainer />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-billing-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('columnheader', { name: /data ingested/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables upgrade when subscription create is denied', async () => {
|
||||
server.use(setupAuthzAllow(SubscriptionReadPermission));
|
||||
|
||||
render(<BillingContainer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByTestId('upgrade-plan-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables manage billing when subscription update is denied', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(SubscriptionReadPermission, SubscriptionCreatePermission),
|
||||
);
|
||||
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByTestId('upgrade-plan-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables manage billing when subscription list is denied', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(
|
||||
SubscriptionReadPermission,
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionUpdatePermission,
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
margin: 0 auto var(--spacing-20);
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
.pageHeaderTitle {
|
||||
font-weight: var(--label-medium-500-font-weight);
|
||||
@@ -41,8 +41,6 @@
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
:global(.ant-card) {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
@@ -60,12 +58,8 @@
|
||||
margin: var(--spacing-12) var(--spacing-4);
|
||||
}
|
||||
|
||||
.usageDenied {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.billingDetails {
|
||||
margin: var(--spacing-4) 0;
|
||||
margin: var(--spacing-12) 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
@@ -134,7 +128,7 @@
|
||||
}
|
||||
|
||||
.upgradePlanBenefits {
|
||||
margin: 0;
|
||||
margin: 0 var(--spacing-4);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 var(--padding-12);
|
||||
@@ -182,7 +176,7 @@
|
||||
}
|
||||
|
||||
.billingUpdateNote {
|
||||
margin-top: var(--spacing-4);
|
||||
margin-top: var(--spacing-8);
|
||||
font-family: var(--font-family-inter);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { billingSuccessResponse } from 'mocks-server/__mockdata__/billing';
|
||||
import {
|
||||
licensesSuccessResponse,
|
||||
notOfTrailResponse,
|
||||
trialConvertedToSubscriptionResponse,
|
||||
} from 'mocks-server/__mockdata__/licenses';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
import {
|
||||
@@ -17,6 +15,11 @@ import { getFormattedDate } from 'utils/timeUtils';
|
||||
|
||||
import BillingContainer from './BillingContainer';
|
||||
|
||||
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
|
||||
}));
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
@@ -28,22 +31,14 @@ window.ResizeObserver =
|
||||
describe('BillingContainer', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('Component should render', async () => {
|
||||
render(<BillingContainer />);
|
||||
|
||||
const dataInjection = await screen.findByRole('columnheader', {
|
||||
const dataInjection = screen.getByRole('columnheader', {
|
||||
name: /data ingested/i,
|
||||
});
|
||||
expect(dataInjection).toBeInTheDocument();
|
||||
const pricePerUnit = await screen.findByRole('columnheader', {
|
||||
const pricePerUnit = screen.getByRole('columnheader', {
|
||||
name: /price per unit/i,
|
||||
});
|
||||
expect(pricePerUnit).toBeInTheDocument();
|
||||
@@ -54,15 +49,13 @@ describe('BillingContainer', () => {
|
||||
|
||||
const dayRemainingInBillingPeriod = await screen.findByText(
|
||||
/Please upgrade plan now to retain your data./i,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
|
||||
const upgradePlanButton = screen.getByTestId('upgrade-plan-button');
|
||||
expect(upgradePlanButton).toBeInTheDocument();
|
||||
|
||||
const dollar = await screen.findByText(/\$1,278.3/i, {}, { timeout: 5000 });
|
||||
const dollar = await screen.findByText(/\$1,278.3/i);
|
||||
expect(dollar).toBeInTheDocument();
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
@@ -93,9 +86,7 @@ describe('BillingContainer', () => {
|
||||
|
||||
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/\$0/i, {}, { timeout: 5000 }),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
@@ -141,7 +132,7 @@ describe('BillingContainer', () => {
|
||||
const currentBill = await screen.findByText('billing');
|
||||
expect(currentBill).toBeInTheDocument();
|
||||
|
||||
const dollar0 = await screen.findByText(/\$0/i, {}, { timeout: 5000 });
|
||||
const dollar0 = await screen.findByText(/\$0/i);
|
||||
expect(dollar0).toBeInTheDocument();
|
||||
|
||||
const onTrail = await screen.findByText(
|
||||
@@ -259,11 +250,7 @@ describe('BillingContainer', () => {
|
||||
billingSuccessResponse.data.billingPeriodStart,
|
||||
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
|
||||
|
||||
const billingPeriod = await findByText(
|
||||
billingPeriodText,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
const billingPeriod = await findByText(billingPeriodText);
|
||||
expect(billingPeriod).toBeInTheDocument();
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
|
||||
import {
|
||||
Card,
|
||||
@@ -15,36 +15,25 @@ import {
|
||||
TableColumnsType as ColumnsType,
|
||||
} from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import getUsage, {
|
||||
BreakdownEntry,
|
||||
UsageResponsePayloadProps,
|
||||
} from 'api/billing/getUsage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type {
|
||||
CreateSubscription201,
|
||||
GetSubscription200,
|
||||
SubscriptiontypesGettableSubscriptionUsageDTO,
|
||||
SubscriptiontypesSubscriptionUsageBreakdownDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
createSubscription,
|
||||
updateSubscription,
|
||||
useGetSubscription,
|
||||
} from 'api/generated/services/subscriptions';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useAxiosError from 'hooks/useAxiosError';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty, pick } from 'lodash-es';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import {
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionManagePermissions,
|
||||
SubscriptionReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
||||
|
||||
@@ -146,7 +135,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
||||
const [data, setData] = useState<DataType[]>([]);
|
||||
const [apiResponse, setApiResponse] = useState<
|
||||
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
|
||||
Partial<UsageResponsePayloadProps>
|
||||
>({});
|
||||
|
||||
const {
|
||||
@@ -157,8 +146,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
activeLicense,
|
||||
activeLicenseFetchError,
|
||||
} = useAppContext();
|
||||
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
|
||||
useAuthZ([SubscriptionReadPermission]);
|
||||
const { licenseKey } = useActiveLicenseKey();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleError = useAxiosError();
|
||||
@@ -166,34 +154,33 @@ export default function BillingContainer(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const processUsageData = useCallback(
|
||||
(response: GetSubscription200): void => {
|
||||
const usage = response?.data;
|
||||
if (isEmpty(usage)) {
|
||||
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
|
||||
if (isEmpty(data?.payload)) {
|
||||
return;
|
||||
}
|
||||
const breakdown = usage.details?.breakdown ?? [];
|
||||
const billTotal = usage.details?.billTotal ?? 0;
|
||||
const billingPeriodStart = usage.billingPeriodStart ?? 0;
|
||||
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
|
||||
const {
|
||||
details: { breakdown = [], billTotal },
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
|
||||
const formattedUsageData: DataType[] = [];
|
||||
|
||||
breakdown.forEach(
|
||||
(
|
||||
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
|
||||
index: number,
|
||||
) => {
|
||||
element?.tiers?.forEach((tier, tierIndex: number) => {
|
||||
if (breakdown && Array.isArray(breakdown)) {
|
||||
for (let index = 0; index < breakdown.length; index += 1) {
|
||||
const element: BreakdownEntry = breakdown[index];
|
||||
|
||||
element?.tiers?.forEach((tier, i: number) => {
|
||||
formattedUsageData.push({
|
||||
key: `${index}${tierIndex}`,
|
||||
name: tierIndex === 0 ? (element?.type ?? '') : '',
|
||||
key: `${index}${i}`,
|
||||
name: i === 0 ? element?.type : '',
|
||||
unit: element?.unit ?? '',
|
||||
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||
pricePerUnit: String(tier.unitPrice),
|
||||
cost: `$ ${tier.tierCost}`,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setData(formattedUsageData);
|
||||
|
||||
@@ -209,7 +196,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
setBillAmount(billTotal);
|
||||
}
|
||||
|
||||
setApiResponse(usage);
|
||||
setApiResponse(data?.payload || {});
|
||||
},
|
||||
[trialInfo?.onTrial],
|
||||
);
|
||||
@@ -221,12 +208,11 @@ export default function BillingContainer(): JSX.Element {
|
||||
isLoading,
|
||||
isFetching: isFetchingBillingData,
|
||||
data: billingData,
|
||||
} = useGetSubscription({
|
||||
query: {
|
||||
enabled: canReadSubscription || !!subscriptionAuthZError,
|
||||
onError: handleError,
|
||||
onSuccess: processUsageData,
|
||||
},
|
||||
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
|
||||
queryFn: () => getUsage(licenseKey || ''),
|
||||
onError: handleError,
|
||||
enabled: !!licenseKey,
|
||||
onSuccess: processUsageData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -298,7 +284,9 @@ export default function BillingContainer(): JSX.Element {
|
||||
/>
|
||||
);
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -315,7 +303,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
updateCreditCardApi,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
@@ -325,7 +313,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
);
|
||||
|
||||
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
|
||||
useMutation(updateSubscription, {
|
||||
useMutation(manageCreditCardApi, {
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
@@ -360,21 +348,15 @@ export default function BillingContainer(): JSX.Element {
|
||||
updateCreditCard,
|
||||
]);
|
||||
|
||||
const billingActionPermissions = trialInfo?.trialConvertedToSubscription
|
||||
? SubscriptionManagePermissions
|
||||
: [SubscriptionCreatePermission];
|
||||
|
||||
const subscriptionPastDueMessage = (): JSX.Element => (
|
||||
<Typography>
|
||||
{`We were not able to process payments for your account. Please update your card details `}
|
||||
<AuthZTooltip checks={billingActionPermissions}>
|
||||
<Typography.Link
|
||||
onClick={handleBilling}
|
||||
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
|
||||
>
|
||||
{t('here')}
|
||||
</Typography.Link>
|
||||
</AuthZTooltip>
|
||||
<Typography.Link
|
||||
onClick={handleBilling}
|
||||
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
|
||||
>
|
||||
{t('here')}
|
||||
</Typography.Link>
|
||||
{` if your payment information has changed. Email us at `}
|
||||
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
|
||||
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
|
||||
@@ -429,7 +411,11 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Card bordered={false} className={styles.pageInfo}>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ minHeight: 150, marginBottom: 16 }}
|
||||
className={styles.pageInfo}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex vertical gap={8}>
|
||||
<p className={styles.pageInfoTitle}>
|
||||
@@ -437,14 +423,13 @@ export default function BillingContainer(): JSX.Element {
|
||||
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
|
||||
</p>
|
||||
|
||||
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
|
||||
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
|
||||
<p className={styles.pageInfoSubtitle}>
|
||||
{daysRemaining} {daysRemainingStr}
|
||||
</p>
|
||||
) : null}
|
||||
</Flex>
|
||||
<AuthZButton
|
||||
checks={billingActionPermissions}
|
||||
<Button
|
||||
testId="header-billing-button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -458,7 +443,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
{trialInfo?.trialConvertedToSubscription
|
||||
? t('manage_billing')
|
||||
: t('upgrade_plan')}
|
||||
</AuthZButton>
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
|
||||
@@ -510,81 +495,66 @@ export default function BillingContainer(): JSX.Element {
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<AuthZGuardContent
|
||||
checks={[SubscriptionReadPermission]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
className={styles.usageDenied}
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className={styles.emptyGraphCard} bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
)}
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<div className={styles.billingGraphFooter}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleCsvDownload}
|
||||
prefix={<MonitorDown size={14} />}
|
||||
testId="download-csv-button"
|
||||
className={styles.billingFooterBtn}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Callout type="info" size="small" className={styles.billingUpdateNote}>
|
||||
Billing metrics are updated once every 24 hours.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className={styles.billingDetails}>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ({
|
||||
style,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
|
||||
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
style={safeStyle}
|
||||
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className={styles.emptyGraphCard} bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
)}
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<div className={styles.billingGraphFooter}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleCsvDownload}
|
||||
prefix={<MonitorDown size={14} />}
|
||||
testId="download-csv-button"
|
||||
className={styles.billingFooterBtn}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<RefreshPaymentStatus
|
||||
type="button"
|
||||
className={styles.billingFooterBtn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Callout type="info" size="small" className={styles.billingUpdateNote}>
|
||||
Billing metrics are updated once every 24 hours.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className={styles.billingDetails}>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ({
|
||||
style,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
|
||||
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
style={safeStyle}
|
||||
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
|
||||
</div>
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
|
||||
</div>
|
||||
|
||||
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
|
||||
<CancelSubscriptionBanner />
|
||||
@@ -627,8 +597,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<AuthZButton
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
<Button
|
||||
testId="upgrade-plan-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -637,7 +606,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
onClick={handleBilling}
|
||||
>
|
||||
{t('upgrade_plan')}
|
||||
</AuthZButton>
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import type uPlot from 'uplot';
|
||||
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
|
||||
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
|
||||
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import styles from './BillingUsageGraph.module.scss';
|
||||
|
||||
interface BillingUsageGraphProps {
|
||||
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
|
||||
data: Partial<UsageResponsePayloadProps>;
|
||||
billAmount: number;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: (currentDay.timestamp ?? 0) + 86400,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
@@ -94,9 +94,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() =>
|
||||
calculateStartEndTime(
|
||||
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
|
||||
),
|
||||
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
@@ -117,9 +117,7 @@ export function csvFileName(csvData: QuantityData[]): string {
|
||||
return `billing_usage_(${startDate}-${endDate}).csv`;
|
||||
}
|
||||
|
||||
export function prepareCsvData(
|
||||
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
|
||||
): {
|
||||
export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
csvData: string;
|
||||
fileName: string;
|
||||
} {
|
||||
@@ -137,14 +135,12 @@ export function prepareCsvData(
|
||||
}
|
||||
|
||||
export function calculateStartEndTime(
|
||||
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
|
||||
data: Partial<UsageResponsePayloadProps>,
|
||||
): { startTime: number | undefined; endTime: number | undefined } {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
|
||||
if (typeof entry.timestamp === 'number') {
|
||||
timestamps.push(entry.timestamp);
|
||||
}
|
||||
timestamps.push(entry.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
margin: var(--spacing-4) 0;
|
||||
margin: var(--spacing-4) 0 var(--spacing-12);
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
@@ -42,24 +36,10 @@ function mockMailto(): {
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionBanner', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('disables Cancel Subscription when subscription delete is denied', async () => {
|
||||
server.use(setupAuthzDeny(SubscriptionDeletePermission));
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders banner with title and subtitle', () => {
|
||||
render(<CancelSubscriptionBanner />);
|
||||
expect(
|
||||
@@ -76,10 +56,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -97,10 +76,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
@@ -117,10 +95,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByTestId('cancel-confirm-input');
|
||||
await user.type(input, 'cancel');
|
||||
@@ -130,10 +107,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
@@ -143,10 +119,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
@@ -176,10 +151,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
@@ -198,10 +172,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
@@ -219,10 +192,9 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
@@ -180,17 +178,15 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
immediately and removed from our servers.
|
||||
</span>
|
||||
</div>
|
||||
<AuthZButton
|
||||
checks={[SubscriptionDeletePermission]}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={12} />}
|
||||
onClick={handleOpenCancelDialog}
|
||||
className={styles.cancelButton}
|
||||
testId="cancel-subscription-btn"
|
||||
>
|
||||
Cancel Subscription
|
||||
</AuthZButton>
|
||||
</Button>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
open={dialogView !== null}
|
||||
|
||||
@@ -11,13 +11,17 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
@@ -48,7 +52,6 @@ import {
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
@@ -115,7 +118,7 @@ function Explorer(): JSX.Element {
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryAIWithType,
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
@@ -182,7 +185,7 @@ function Explorer(): JSX.Element {
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
|
||||
@@ -17,11 +17,12 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
@@ -42,7 +43,6 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
@@ -94,7 +94,7 @@ function ListView({
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -8,16 +10,33 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: false, isDisabled: true },
|
||||
having: { isHidden: false, isDisabled: true },
|
||||
}),
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
@@ -26,10 +45,14 @@ function QuerySection(): JSX.Element {
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={isListViewPanel}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,9 +14,10 @@ import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
@@ -30,7 +31,6 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
@@ -60,7 +60,7 @@ function TracesView({
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
stagedQuery: Query,
|
||||
orderBy?: string,
|
||||
): Query => {
|
||||
const query = stagedQuery
|
||||
? cloneDeep(stagedQuery)
|
||||
: cloneDeep(initialQueriesMap.traces);
|
||||
|
||||
const orderByPayload: OrderByPayload[] = orderBy
|
||||
? [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
for (let i = 0; i < query.builder.queryData.length; i++) {
|
||||
const queryData = query.builder.queryData[i];
|
||||
queryData.groupBy = [];
|
||||
queryData.having = {
|
||||
expression: '',
|
||||
};
|
||||
queryData.orderBy = orderByPayload;
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
stagedQuery: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
|
||||
return getListViewQuery(stagedQuery);
|
||||
}
|
||||
return stagedQuery;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
options: OptionsQuery,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const updatedQuery = cloneDeep(query);
|
||||
set(
|
||||
updatedQuery,
|
||||
'builder.queryData[0].selectColumns',
|
||||
options.selectColumns,
|
||||
);
|
||||
|
||||
return updatedQuery;
|
||||
}
|
||||
return query;
|
||||
};
|
||||
@@ -108,6 +108,7 @@ function LogsExplorerViewsContainer({
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
@@ -179,7 +180,12 @@ function LogsExplorerViewsContainer({
|
||||
},
|
||||
undefined,
|
||||
listQueryKeyRef,
|
||||
undefined,
|
||||
{
|
||||
...(!isEmpty(queryId) &&
|
||||
selectedPanelType !== PANEL_TYPES.LIST && {
|
||||
'X-SIGNOZ-QUERY-ID': queryId,
|
||||
}),
|
||||
},
|
||||
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
|
||||
'custom',
|
||||
);
|
||||
@@ -244,6 +250,10 @@ function LogsExplorerViewsContainer({
|
||||
setRequestData(newRequestData);
|
||||
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryId(v4());
|
||||
}, [data]);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
|
||||
@@ -329,17 +329,16 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result).toHaveLength(8);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'license',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -420,17 +419,16 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result).toHaveLength(8);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'subscription',
|
||||
'license',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Gauge,
|
||||
Key,
|
||||
Logs,
|
||||
Receipt,
|
||||
Shield,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
@@ -70,13 +69,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
selectorPlaceholder: 'Type license ID, separate multiple with comma or space',
|
||||
docsAnchor: 'license',
|
||||
},
|
||||
subscription: {
|
||||
label: 'Subscription',
|
||||
description: 'The workspace subscription, its usage and billing details.',
|
||||
icon: Receipt,
|
||||
selectorPlaceholder: 'Type * to cover the workspace subscription',
|
||||
docsAnchor: 'subscription',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
@@ -115,11 +107,7 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_ORDER = (
|
||||
Object.keys(RESOURCE_PANELS) as AuthZResource[]
|
||||
).sort((left, right) =>
|
||||
RESOURCE_PANELS[left].label.localeCompare(RESOURCE_PANELS[right].label),
|
||||
);
|
||||
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
|
||||
|
||||
export function getResourcePanel(resource: AuthZResource): ResourcePanelConfig {
|
||||
const panel = RESOURCE_PANELS[resource];
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useAutoRefreshSelection } from './useAutoRefreshSelection';
|
||||
import { useAutoRefreshTick } from './useAutoRefreshTick';
|
||||
|
||||
/** Auto-refresh timer for views that hide the time selector that normally owns it. */
|
||||
function AutoRefreshTicker(): null {
|
||||
const { isEnabled, intervalMs } = useAutoRefreshSelection();
|
||||
|
||||
useAutoRefreshTick(isEnabled, intervalMs);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default AutoRefreshTicker;
|
||||
@@ -1,116 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import AutoRefresh from '../index';
|
||||
|
||||
const mockStore = configureStore<Partial<AppState>>([]);
|
||||
|
||||
const PATHNAME = '/dashboard/test-id';
|
||||
const randomTime = 1700000000000000000;
|
||||
|
||||
function createGlobalTimeState(
|
||||
overrides: Partial<GlobalReducer> = {},
|
||||
): GlobalReducer {
|
||||
return {
|
||||
minTime: randomTime,
|
||||
maxTime: randomTime,
|
||||
loading: false,
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '5s',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAutoRefresh(
|
||||
globalTime: GlobalReducer,
|
||||
props: { disabled?: boolean } = {},
|
||||
): MockStoreEnhanced<Partial<AppState>> {
|
||||
const store = mockStore({ globalTime });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[PATHNAME]}>
|
||||
<Provider store={store}>
|
||||
<AutoRefresh {...props} />
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function tickCount(store: MockStoreEnhanced<Partial<AppState>>): number {
|
||||
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL)
|
||||
.length;
|
||||
}
|
||||
|
||||
describe('AutoRefresh', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders the trigger and ticks on the persisted interval', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderAutoRefresh(createGlobalTimeState());
|
||||
|
||||
expect(screen.getByTitle('Set auto refresh')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(15_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(3);
|
||||
});
|
||||
|
||||
it('does not tick when auto refresh was never enabled for the route', () => {
|
||||
const store = renderAutoRefresh(createGlobalTimeState());
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(0);
|
||||
});
|
||||
|
||||
it('does not tick while the disabled prop is set', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderAutoRefresh(createGlobalTimeState(), { disabled: true });
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(0);
|
||||
});
|
||||
|
||||
it('renders nothing on a custom time range', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderAutoRefresh(
|
||||
createGlobalTimeState({ selectedTime: 'custom' }),
|
||||
);
|
||||
|
||||
expect(screen.queryByTitle('Set auto refresh')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(tickCount(store)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import AutoRefresh from '../index';
|
||||
import AutoRefreshTicker from '../AutoRefreshTicker';
|
||||
|
||||
const mockStore = configureStore<Partial<AppState>>([]);
|
||||
|
||||
const PATHNAME = '/dashboard/test-id';
|
||||
const randomTime = 1700000000000000000;
|
||||
|
||||
function createGlobalTimeState(
|
||||
overrides: Partial<GlobalReducer> = {},
|
||||
): GlobalReducer {
|
||||
return {
|
||||
minTime: randomTime,
|
||||
maxTime: randomTime,
|
||||
loading: false,
|
||||
selectedTime: '15m',
|
||||
isAutoRefreshDisabled: false,
|
||||
selectedAutoRefreshInterval: '5s',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTicker(
|
||||
globalTime: GlobalReducer,
|
||||
): MockStoreEnhanced<Partial<AppState>> {
|
||||
const store = mockStore({ globalTime });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[PATHNAME]}>
|
||||
<Provider store={store}>
|
||||
<AutoRefreshTicker />
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function timeIntervalActions(
|
||||
store: MockStoreEnhanced<Partial<AppState>>,
|
||||
): unknown[] {
|
||||
return store.getActions().filter((a) => a.type === UPDATE_TIME_INTERVAL);
|
||||
}
|
||||
|
||||
describe('AutoRefreshTicker', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('advances the global time window on the interval persisted for the route', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderTicker(createGlobalTimeState());
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not tick when the route has no persisted interval', () => {
|
||||
const store = renderTicker(createGlobalTimeState());
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not tick while auto refresh is globally disabled', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderTicker(
|
||||
createGlobalTimeState({ isAutoRefreshDisabled: true }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not tick on a custom time range', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = renderTicker(createGlobalTimeState({ selectedTime: 'custom' }));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(timeIntervalActions(store)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors DashboardContainer's swap: exactly one of the two must be ticking.
|
||||
describe('AutoRefresh full screen handover', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps a single timer running across entering and leaving full screen', () => {
|
||||
set(DASHBOARD_TIME_IN_DURATION, JSON.stringify({ [PATHNAME]: '5s' }));
|
||||
|
||||
const store = mockStore({ globalTime: createGlobalTimeState() });
|
||||
|
||||
function Harness({ active }: { active: boolean }): JSX.Element {
|
||||
return active ? <AutoRefreshTicker /> : <AutoRefresh />;
|
||||
}
|
||||
|
||||
const renderHarness = (active: boolean): JSX.Element => (
|
||||
<MemoryRouter initialEntries={[PATHNAME]}>
|
||||
<Provider store={store}>
|
||||
<Harness active={active} />
|
||||
</Provider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const { rerender } = render(renderHarness(false));
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(timeIntervalActions(store)).toHaveLength(2);
|
||||
|
||||
rerender(renderHarness(true));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(timeIntervalActions(store)).toHaveLength(4);
|
||||
|
||||
rerender(renderHarness(false));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(10_000);
|
||||
});
|
||||
expect(timeIntervalActions(store)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useInterval } from 'react-use';
|
||||
import { Check, ChevronDown } from '@signozhq/icons';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
@@ -10,18 +11,21 @@ import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
import _omit from 'lodash-es/omit';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_AUTO_REFRESH_INTERVAL } from 'types/actions/globalTime';
|
||||
import {
|
||||
UPDATE_AUTO_REFRESH_INTERVAL,
|
||||
UPDATE_TIME_INTERVAL,
|
||||
} from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { refreshIntervalOptions } from './constants';
|
||||
import { ButtonContainer } from './styles';
|
||||
import { useAutoRefreshTick } from './useAutoRefreshTick';
|
||||
|
||||
import './AutoRefreshV2.styles.scss';
|
||||
|
||||
@@ -89,10 +93,30 @@ function AutoRefresh({
|
||||
[selectedOption],
|
||||
);
|
||||
|
||||
useAutoRefreshTick(
|
||||
!isDisabled && isAutoRefreshEnabled && selectedOption !== 'off',
|
||||
getOption?.value || 0,
|
||||
);
|
||||
useInterval(() => {
|
||||
const selectedValue = getOption?.value;
|
||||
|
||||
if (isDisabled || !isAutoRefreshEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOption !== 'off' && selectedValue) {
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: {
|
||||
maxTime,
|
||||
minTime,
|
||||
selectedTime: globalTime.selectedTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, getOption?.value || 0);
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(selectedValue: string) => {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
|
||||
|
||||
import { refreshIntervalOptions } from './constants';
|
||||
|
||||
export interface AutoRefreshSelection {
|
||||
isEnabled: boolean;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* An entry for the current route means auto-refresh is on, its absence means off.
|
||||
* Read on every render because localStorage isn't reactive.
|
||||
*/
|
||||
export function useAutoRefreshSelection(): AutoRefreshSelection {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const selectedOption = JSON.parse(get(DASHBOARD_TIME_IN_DURATION) || '{}')[
|
||||
pathname
|
||||
];
|
||||
|
||||
return {
|
||||
isEnabled: Boolean(selectedOption),
|
||||
intervalMs:
|
||||
refreshIntervalOptions.find((option) => option.key === selectedOption)
|
||||
?.value || 0,
|
||||
};
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useInterval } from 'react-use';
|
||||
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppActions from 'types/actions';
|
||||
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
/**
|
||||
* Advances the global time window on the auto-refresh interval. The global
|
||||
* "auto refresh disabled" flag and a custom range override the caller's `enabled`.
|
||||
*/
|
||||
export function useAutoRefreshTick(enabled: boolean, intervalMs: number): void {
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
|
||||
const isTicking =
|
||||
enabled &&
|
||||
intervalMs > 0 &&
|
||||
!globalTime.isAutoRefreshDisabled &&
|
||||
globalTime.selectedTime !== 'custom';
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
const { maxTime, minTime } = getMinMaxForSelectedTime(
|
||||
globalTime.selectedTime,
|
||||
globalTime.minTime,
|
||||
globalTime.maxTime,
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: UPDATE_TIME_INTERVAL,
|
||||
payload: {
|
||||
maxTime,
|
||||
minTime,
|
||||
selectedTime: globalTime.selectedTime,
|
||||
},
|
||||
});
|
||||
},
|
||||
isTicking ? intervalMs : null,
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,6 @@ export default {
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'subscription',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'role',
|
||||
type: 'role',
|
||||
|
||||
@@ -4,5 +4,3 @@ import type { BrandedPermission } from '../types';
|
||||
// Resource-level — require a specific license id
|
||||
export const buildLicenseReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `license:${id}`);
|
||||
export const buildLicenseUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `license:${id}`);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { buildPermission } from '../utils';
|
||||
|
||||
export const SubscriptionReadPermission = buildPermission(
|
||||
'read',
|
||||
'subscription:*',
|
||||
);
|
||||
export const SubscriptionCreatePermission = buildPermission(
|
||||
'create',
|
||||
'subscription:*',
|
||||
);
|
||||
export const SubscriptionUpdatePermission = buildPermission(
|
||||
'update',
|
||||
'subscription:*',
|
||||
);
|
||||
export const SubscriptionListPermission = buildPermission(
|
||||
'list',
|
||||
'subscription:*',
|
||||
);
|
||||
export const SubscriptionDeletePermission = buildPermission(
|
||||
'delete',
|
||||
'subscription:*',
|
||||
);
|
||||
export const SubscriptionManagePermissions = [
|
||||
SubscriptionListPermission,
|
||||
SubscriptionUpdatePermission,
|
||||
];
|
||||
@@ -4,85 +4,114 @@ export const quickFiltersListResponse = {
|
||||
signal: 'logs',
|
||||
filters: [
|
||||
{
|
||||
name: 'os.description',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'duration_nano',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
key: 'duration_nano',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
fieldDataType: 'float64',
|
||||
fieldContext: 'attribute',
|
||||
key: 'quantity',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
name: 'body',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
key: 'body',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
{
|
||||
name: 'deployment.environment',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'service.namespace',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'k8s.namespace.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'service.instance.id',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'k8s.pod.name',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
name: 'process.owner',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
key: 'process.owner',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
|
||||
[name]: [
|
||||
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
|
||||
],
|
||||
});
|
||||
|
||||
export const otherFiltersResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
...otherFilterName('service.name'),
|
||||
...otherFilterName('k8s.deployment.name'),
|
||||
...otherFilterName('deployment.environment'),
|
||||
...otherFilterName('service.namespace'),
|
||||
...otherFilterName('k8s.namespace.name'),
|
||||
...otherFilterName('service.instance.id'),
|
||||
...otherFilterName('k8s.pod.name'),
|
||||
...otherFilterName('k8s.pod.uid'),
|
||||
...otherFilterName('os.description'),
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.deployment.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.namespace',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.namespace.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'k8s.pod.uid',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'os.description',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export const handlers = [
|
||||
res(ctx.status(200), ctx.json(licensesSuccessResponse)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/subscriptions', (req, res, ctx) =>
|
||||
rest.get('http://localhost/api/v1/billing', (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(billingSuccessResponse)),
|
||||
),
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@ 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 '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
@@ -67,7 +68,7 @@ function ConfigPane({
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[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.
|
||||
|
||||
@@ -5,8 +5,18 @@ 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;
|
||||
@@ -34,6 +44,7 @@ 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']
|
||||
|
||||
@@ -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_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
label: displayName,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
value: kind,
|
||||
label: displayName,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
|
||||
@@ -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 '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ 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.
|
||||
|
||||
@@ -26,3 +26,16 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
@@ -21,20 +21,15 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
|
||||
panelKind: PanelKind;
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** 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. */
|
||||
@@ -55,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelKind,
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
@@ -65,10 +60,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[panelKind];
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelKind === 'signoz/ListPanel';
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -99,12 +94,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(
|
||||
() => getHiddenQueryBuilderFields(panelKind, signal),
|
||||
[panelKind, signal],
|
||||
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
|
||||
[panelDefinition.queryBuilderFields, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -151,7 +146,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -4,6 +4,9 @@ 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.
|
||||
@@ -48,7 +51,7 @@ function renderBuilder(
|
||||
): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind as never}
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
|
||||
@@ -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 { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { RenderableQueryPanelDefinition } 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;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
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
|
||||
panelDefinition={panelDefinition}
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
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;
|
||||
@@ -0,0 +1,135 @@
|
||||
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;
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -232,7 +233,12 @@ describe('PanelEditorContainer composition', () => {
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
expect.objectContaining({
|
||||
panelDefinition: expect.objectContaining({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
mode: 'query',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -256,7 +262,7 @@ describe('PanelEditorContainer composition', () => {
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -18,6 +18,8 @@ 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.
|
||||
@@ -83,7 +85,7 @@ function EditorRoute(): JSX.Element {
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
|
||||
@@ -19,6 +19,10 @@ 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(),
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
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. 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.
|
||||
* legend-colors control can key overrides by the exact labels the chart draws, using
|
||||
* the resolver the kind declares as its `colors` control.
|
||||
*/
|
||||
export function useLegendSeries(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
data: PanelQueryData,
|
||||
): LegendSeries[] {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const kind = panel.spec.plugin.kind;
|
||||
|
||||
return useMemo(() => {
|
||||
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]);
|
||||
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
|
||||
return resolve
|
||||
? resolve({ queries: panel.spec.queries, data, isDarkMode })
|
||||
: [];
|
||||
}, [kind, panel.spec.queries, data, isDarkMode]);
|
||||
}
|
||||
|
||||
@@ -4,24 +4,19 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
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 { 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 {
|
||||
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;
|
||||
@@ -38,6 +33,12 @@ 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 {
|
||||
@@ -50,7 +51,7 @@ export interface UsePanelEditSessionReturn {
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
@@ -62,8 +63,6 @@ export interface UsePanelEditSessionReturn {
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,14 +79,17 @@ export function usePanelEditSession({
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
draftApi,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
|
||||
panel,
|
||||
savedPanel,
|
||||
);
|
||||
// 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 panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
// Hosts fork on `definition.mode` before mounting this session (the editor and
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
@@ -109,12 +111,6 @@ export function usePanelEditSession({
|
||||
savedQueries: savedPanel?.spec.queries,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
@@ -128,6 +124,5 @@ export function usePanelEditSession({
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import type {
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
isQuerylessPanelKind,
|
||||
resolveQueryType,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -128,11 +131,25 @@ export function usePanelTypeSwitch({
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
// 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.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
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), []));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,13 @@
|
||||
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 type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
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 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';
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
|
||||
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 {
|
||||
export interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -74,293 +31,42 @@ interface PanelEditorContainerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
const { panel, savedPanel } = props;
|
||||
const draftApi = usePanelEditorDraft(panel, savedPanel);
|
||||
|
||||
// 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,
|
||||
});
|
||||
const panelKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
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,
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
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]);
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticEditorBody
|
||||
{...props}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<QueryEditorBody
|
||||
{...props}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryTypes,
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
@@ -107,7 +109,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(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
|
||||
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
|
||||
EXPECTED_QUERY_CAPABILITIES[kind],
|
||||
);
|
||||
});
|
||||
@@ -149,7 +151,8 @@ describe('panel capabilities guard', () => {
|
||||
});
|
||||
|
||||
it('carries an inert query shape, so a stray request can do no harm', () => {
|
||||
const { queryCapabilities } = getPanelDefinition(unknownKind);
|
||||
const queryCapabilities = requireQueryPanelDefinition(unknownKind)
|
||||
.queryCapabilities;
|
||||
expect(queryCapabilities.requestType).toBe(time_series);
|
||||
expect(queryCapabilities.serverPaginated).toBe(false);
|
||||
expect(queryCapabilities.formatTableResultForUI).toBe(false);
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import type { FilterConfigsPartial } from './types/panelCapabilities';
|
||||
import {
|
||||
mergeQueryBuilderFieldRule,
|
||||
type FilterConfigsPartial,
|
||||
} from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
/**
|
||||
@@ -13,11 +17,46 @@ 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 getPanelDefinition(kind).supportedSignals;
|
||||
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
|
||||
}
|
||||
|
||||
export function isSignalSupported(
|
||||
@@ -29,7 +68,7 @@ export function isSignalSupported(
|
||||
|
||||
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
|
||||
return getPanelDefinition(kind).supportedQueryTypes;
|
||||
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
|
||||
}
|
||||
|
||||
export function isQueryTypeSupportedByPanelKind(
|
||||
@@ -53,6 +92,10 @@ 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;
|
||||
}
|
||||
@@ -73,7 +116,11 @@ export function resolveQueryType(
|
||||
preferred: EQueryType,
|
||||
): EQueryType {
|
||||
const supported = getSupportedQueryTypes(kind);
|
||||
return supported.includes(preferred) ? preferred : supported[0];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +132,6 @@ export function getHiddenQueryBuilderFields(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const rule = getPanelDefinition(kind).queryBuilderFields;
|
||||
const perSignal = signal ? rule[signal] : undefined;
|
||||
return { ...rule.default, ...perSignal };
|
||||
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
|
||||
return mergeQueryBuilderFieldRule(rule, signal);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 {
|
||||
@@ -10,7 +13,10 @@ 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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
@@ -13,7 +14,10 @@ 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: true } },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.LABEL },
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 {
|
||||
@@ -10,7 +13,10 @@ 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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
@@ -9,7 +10,7 @@ export const sections: SectionConfig[] = [
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: true },
|
||||
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
|
||||
// Merging all queries collapses to one distribution with no legend.
|
||||
isHidden: (spec): boolean =>
|
||||
Boolean(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
@@ -1,4 +1,7 @@
|
||||
import { List } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import ListEditorPane from './ListEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -11,7 +14,10 @@ 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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user