Compare commits

..

1 Commits

Author SHA1 Message Date
Abhi Kumar
53100381a5 refactor(query-builder): compose the panel-type field map instead of listing it
`panelTypeDataSourceFormValuesMap` spelled out all 21 panel-type x data-source
combinations as literal field lists, 435 lines of them. The combinations reduce to
seven distinct sets: logs and traces carry identical fields in every case, metrics
adds its two aggregation steps, and each panel type is one of four query shapes.
Most of the apparent variation was ordering noise — the sets for a bar chart and a
table on logs are equal, listed in a different order.

Composed from those rules it is 84 lines, and the policy is legible: charts, table
and pie share a surface, table and pie differ only by `reduceTo` on metrics, a
single value has nothing to group or order, and raw rows carry no aggregation. Two
asymmetries that were buried in the literals are called out where they are decided
rather than reproduced silently.

No behaviour change: the composition was checked cell by cell against the previous
table before it was removed. The specs pin the rules rather than the values, so they
fail when a rule changes — the moment to stop and decide — instead of whenever a
field moves. One of them states the hazard composing introduces: the aggregating
types share a field list, so an edit meant for charts reaches table and pie too.
Another pins one array per cell, because the QueryBuilder provider pushes onto the
list it reads from this map.

Assisted-by: Claude Opus 5
2026-09-07 14:22:22 +05:30
95 changed files with 1222 additions and 6054 deletions

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
export interface DayBreakdownEntry {
timestamp: number;
total: number;
quantity: number;
count: number;
size: number;
}
export interface TierEntry {
quantity: number;
unitPrice: number;
tierCost: number;
}
export interface BreakdownEntry {
type: string;
unit: string;
dayWiseBreakdown: {
breakdown: DayBreakdownEntry[];
};
tiers?: TierEntry[];
}
export interface UsageResponsePayloadProps {
billingPeriodStart: number;
billingPeriodEnd: number;
details: {
total: number;
baseFee: number;
breakdown: BreakdownEntry[];
billTotal: number;
};
discount: number;
subscriptionStatus?: string;
}
const getUsage = async (
licenseKey: string,
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getUsage;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,13 +16,11 @@ import * as Sentry from '@sentry/react';
import { Toaster } from '@signozhq/ui/sonner';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Flex } from 'antd';
import { Button } from '@signozhq/ui/button';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
import logEvent from 'api/common/logEvent';
import { 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';
@@ -32,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';
@@ -67,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,
@@ -80,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';
@@ -168,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;
@@ -186,7 +186,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
};
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(updateSubscription, {
useMutation(manageCreditCardApi, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -469,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({
@@ -584,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,
@@ -788,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>
)}
@@ -816,25 +826,22 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
.
</span>
<span>
{' '}
Please{' '}
<AuthZTooltip checks={SubscriptionManagePermissions}>
<Button
variant="link"
color="none"
className="upgrade-link"
onClick={handleFailedPayment}
>
pay the bill
</Button>
</AuthZTooltip>
to continue using SigNoz features.
<span className="refresh-payment-status">
{user.role === USER_ROLES.ADMIN ? (
<span>
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
Please{' '}
<a className="upgrade-link" onClick={handleFailedPayment}>
pay the bill
</a>
to continue using SigNoz features.
<span className="refresh-payment-status">
{' '}
| Already paid? <RefreshPaymentStatus type="text" />
</span>
</span>
</span>
) : (
' Please contact your administrator to pay the bill.'
)}
</div>
)}
</div>

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
import {
Card,
@@ -15,35 +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 {
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';
@@ -145,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 {
@@ -156,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();
@@ -165,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);
@@ -208,7 +196,7 @@ export default function BillingContainer(): JSX.Element {
setBillAmount(billTotal);
}
setApiResponse(usage);
setApiResponse(data?.payload || {});
},
[trialInfo?.onTrial],
);
@@ -220,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(() => {
@@ -297,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;
@@ -314,7 +303,7 @@ export default function BillingContainer(): JSX.Element {
};
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
createSubscription,
updateCreditCardApi,
{
onSuccess: (data) => {
handleBillingOnSuccess(data);
@@ -324,7 +313,7 @@ export default function BillingContainer(): JSX.Element {
);
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
useMutation(updateSubscription, {
useMutation(manageCreditCardApi, {
onSuccess: (data) => {
handleBillingOnSuccess(data);
},
@@ -359,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.`}
@@ -428,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}>
@@ -436,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"
@@ -457,7 +443,7 @@ export default function BillingContainer(): JSX.Element {
{trialInfo?.trialConvertedToSubscription
? t('manage_billing')
: t('upgrade_plan')}
</AuthZButton>
</Button>
</Flex>
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
@@ -509,73 +495,66 @@ export default function BillingContainer(): JSX.Element {
))}
</Card>
<AuthZGuardContent checks={[SubscriptionReadPermission]}>
<>
<div className={styles.billingGraphSection}>
{!isLoading && !isFetchingBillingData ? (
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
) : (
<Card className={styles.emptyGraphCard} bordered={false}>
<Spinner size="large" tip="Loading..." height="35vh" />
</Card>
)}
{!isLoading && !isFetchingBillingData && (
<div className={styles.billingGraphFooter}>
<Button
variant="outlined"
color="secondary"
size="md"
onClick={handleCsvDownload}
prefix={<MonitorDown size={14} />}
testId="download-csv-button"
className={styles.billingFooterBtn}
>
Download CSV
</Button>
<RefreshPaymentStatus
type="button"
className={styles.billingFooterBtn}
/>
</div>
)}
<div 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>
{!isLoading && !isFetchingBillingData && (
<Callout type="info" size="small" className={styles.billingUpdateNote}>
Billing metrics are updated once every 24 hours.
</Callout>
)}
)}
</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.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 />
@@ -618,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"
@@ -628,7 +606,7 @@ export default function BillingContainer(): JSX.Element {
onClick={handleBilling}
>
{t('upgrade_plan')}
</AuthZButton>
</Button>
</Col>
</Row>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
import {
panelTypeDataSourceFormValuesMap,
type PartialPanelTypes,
} from 'lib/query/panelQuery';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* The map is composed from a few shape rules rather than spelled out per panel type
* and data source. These specs pin the rules themselves — each one fails only when a
* rule changes, which is the moment to stop and decide, rather than whenever any
* field moves.
*
* The composition it replaced was checked cell by cell against the previous literal
* table, which is in git history at `main:frontend/src/lib/query/panelQuery.ts`.
*/
function fieldsFor(
panelType: keyof PartialPanelTypes,
dataSource: DataSource,
): string[] {
return panelTypeDataSourceFormValuesMap[panelType][dataSource].builder
.queryData;
}
/** Fields present in `to` but not in `from`. */
function added(from: string[], to: string[]): string[] {
return to.filter((field) => !from.includes(field)).sort();
}
/** Panel types built on the aggregating field list. */
const AGGREGATING_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.BAR,
PANEL_TYPES.HISTOGRAM,
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
/** Panel types that reduce each series to one cell or slice. */
const SCALAR_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
describe('panelTypeDataSourceFormValuesMap', () => {
const seriesLogs = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
const seriesMetrics = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
it('shares one builder surface between logs and traces', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(
sources[DataSource.TRACES].builder.queryData,
);
});
});
// The provider pushes onto the list it reads from this map, so two cells backed by
// one instance would leak fields into each other.
it('gives every cell its own array instance', () => {
const arrays = Object.values(panelTypeDataSourceFormValuesMap).flatMap(
(sources) =>
Object.values(sources).map((source) => source.builder.queryData),
);
expect(new Set(arrays).size).toBe(arrays.length);
});
// One consequence of composing: the aggregating types share a single field list, so
// an edit meant for charts reaches table and pie too.
it.each(AGGREGATING_TYPES)(
'gives %s the same non-metrics fields as a time series',
(panelType) => {
expect(fieldsFor(panelType, DataSource.LOGS)).toStrictEqual(seriesLogs);
},
);
it('adds both metrics aggregation steps for metrics', () => {
expect(added(seriesLogs, seriesMetrics)).toStrictEqual([
'spaceAggregation',
'timeAggregation',
]);
});
it.each(SCALAR_TYPES)('offers reduceTo to %s on metrics only', (panelType) => {
expect(
added(seriesMetrics, fieldsFor(panelType, DataSource.METRICS)),
).toStrictEqual(['reduceTo']);
expect(fieldsFor(panelType, DataSource.LOGS)).not.toContain('reduceTo');
});
it('drops grouping, paging and ordering for a single value', () => {
const value = fieldsFor(PANEL_TYPES.VALUE, DataSource.LOGS);
expect(added(value, seriesLogs)).toStrictEqual([
'groupBy',
'limit',
'orderBy',
]);
expect(value).toContain('reduceTo');
});
it('offers no aggregation fields to raw rows', () => {
const rows = fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS);
expect(rows).not.toContain('aggregateAttribute');
expect(rows).not.toContain('aggregateOperator');
expect(rows).not.toContain('groupBy');
expect(rows).not.toContain('having');
expect(rows).not.toContain('stepInterval');
});
it('drops paging and ordering for metrics rows', () => {
expect(
added(
fieldsFor(PANEL_TYPES.LIST, DataSource.METRICS),
fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS),
),
).toStrictEqual(['functions', 'limit', 'orderBy']);
});
});

View File

@@ -101,441 +101,99 @@ export type PartialPanelTypes = {
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
/**
* Builder fields carried across a panel-type switch, per panel type and data source.
*
* The 21 combinations reduce to a handful of rules, so they are composed rather than
* spelled out: logs and traces carry the same fields in every case, metrics splits its
* aggregation in two, and each panel type is one of four query shapes. Order is
* irrelevant — `handleQueryChange` copies each field independently.
*
* `panelTypeFormValues` in `__tests__/__fixtures__` pins the previous literal table so
* the composition can be shown to reproduce it exactly.
*/
/** Every field an aggregating query carries — shared by charts, table and pie. */
const AGGREGATING_FIELDS = [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
] as const;
/** Metrics aggregates over time and then over space, so it carries both steps. */
const METRICS_AGGREGATION = ['timeAggregation', 'spaceAggregation'] as const;
function omit(fields: readonly string[], ...omitted: string[]): string[] {
return fields.filter((field) => !omitted.includes(field));
}
const SERIES = [...AGGREGATING_FIELDS];
const SERIES_METRICS = [...SERIES, ...METRICS_AGGREGATION];
// Table and pie reduce each series to a single cell/slice. Note the asymmetry, carried
// over from the previous table: `reduceTo` is offered for metrics only.
const SCALAR_METRICS = [...SERIES_METRICS, 'reduceTo'];
/** A single value has no series to group, limit or order. */
const SINGLE_VALUE = [
...omit(AGGREGATING_FIELDS, 'groupBy', 'limit', 'orderBy'),
'reduceTo',
];
const SINGLE_VALUE_METRICS = [...SINGLE_VALUE, ...METRICS_AGGREGATION];
/** Raw rows carry no aggregation at all. */
const RAW_ROWS = [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
];
// Metrics rows drop paging and ordering too, as before.
const RAW_ROWS_METRICS = ['queryName', 'filters', 'filter', 'aggregations'];
/**
* Logs and traces share a builder surface; metrics is the one that differs.
*
* Each cell gets its own copy. `QueryBuilder`'s provider pushes onto the list it reads
* from this map, so cells sharing one array instance would contaminate each other.
*/
function bySource(
logsAndTraces: readonly string[],
metrics: readonly string[],
): Record<DataSource, any> {
return {
[DataSource.LOGS]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.TRACES]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.METRICS]: { builder: { queryData: [...metrics] } },
};
}
export const panelTypeDataSourceFormValuesMap: Record<
keyof PartialPanelTypes,
Record<DataSource, any>
> = {
[PANEL_TYPES.BAR]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TIME_SERIES]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.HISTOGRAM]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.PIE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.LIST]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: ['queryName', 'filters', 'filter', 'aggregations'],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
},
[PANEL_TYPES.VALUE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'having',
'reduceTo',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.TIME_SERIES]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};
export function handleQueryChange(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -24,11 +23,6 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
export type BuilderQueryType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';

View File

@@ -112,7 +112,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -186,5 +186,4 @@ export const routeWithInitialAuthZSupport = {
WORKSPACE_LOCKED: true,
WORKSPACE_SUSPENDED: true,
WORKSPACE_ACCESS_RESTRICTED: true,
BILLING: true,
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;

View File

@@ -46,10 +46,6 @@ type Alertmanager interface {
// CreateChannel creates a channel for the organization.
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
// CreateNotificationChannel takes the postable rather than a receiver, because
// a receiver carries only the display name.
CreateNotificationChannel(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error

View File

@@ -155,8 +155,8 @@ func (_c *MockAlertmanager_Config_Call) RunAndReturn(run func() alertmanagerserv
}
// CreateChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, receiver)
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, v)
if len(ret) == 0 {
panic("no return value specified for CreateChannel")
@@ -165,17 +165,17 @@ func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string,
var r0 *alertmanagertypes.Channel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
return returnFunc(context1, s, receiver)
return returnFunc(context1, s, v)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
r0 = returnFunc(context1, s, receiver)
r0 = returnFunc(context1, s, v)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.Channel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
r1 = returnFunc(context1, s, receiver)
r1 = returnFunc(context1, s, v)
} else {
r1 = ret.Error(1)
}
@@ -190,12 +190,12 @@ type MockAlertmanager_CreateChannel_Call struct {
// CreateChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - receiver *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, receiver interface{}) *MockAlertmanager_CreateChannel_Call {
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, receiver)}
// - v *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_CreateChannel_Call {
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, v)}
}
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -223,7 +223,7 @@ func (_c *MockAlertmanager_CreateChannel_Call) Return(channel *alertmanagertypes
return _c
}
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
_c.Call.Return(run)
return _c
}
@@ -291,80 +291,6 @@ func (_c *MockAlertmanager_CreateInhibitRules_Call) RunAndReturn(run func(ctx co
return _c
}
// CreateNotificationChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, postableNotificationChannel)
if len(ret) == 0 {
panic("no return value specified for CreateNotificationChannel")
}
var r0 *alertmanagertypes.Channel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
return returnFunc(context1, s, postableNotificationChannel)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) *alertmanagertypes.Channel); ok {
r0 = returnFunc(context1, s, postableNotificationChannel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.Channel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) error); ok {
r1 = returnFunc(context1, s, postableNotificationChannel)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAlertmanager_CreateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNotificationChannel'
type MockAlertmanager_CreateNotificationChannel_Call struct {
*mock.Call
}
// CreateNotificationChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - postableNotificationChannel *alertmanagertypes.PostableNotificationChannel
func (_e *MockAlertmanager_Expecter) CreateNotificationChannel(context1 interface{}, s interface{}, postableNotificationChannel interface{}) *MockAlertmanager_CreateNotificationChannel_Call {
return &MockAlertmanager_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", context1, s, postableNotificationChannel)}
}
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel)) *MockAlertmanager_CreateNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 *alertmanagertypes.PostableNotificationChannel
if args[2] != nil {
arg2 = args[2].(*alertmanagertypes.PostableNotificationChannel)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Return(channel *alertmanagertypes.Channel, err error) *MockAlertmanager_CreateNotificationChannel_Call {
_c.Call.Return(channel, err)
return _c
}
func (_c *MockAlertmanager_CreateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateNotificationChannel_Call {
_c.Call.Return(run)
return _c
}
// CreateRoutePolicies provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateRoutePolicies(ctx context.Context, routeRequests []*alertmanagertypes.PostableRoutePolicy) ([]*alertmanagertypes.GettableRoutePolicy, error) {
ret := _mock.Called(ctx, routeRequests)
@@ -1698,8 +1624,8 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
}
// TestReceiver provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) error {
ret := _mock.Called(context1, s, receiver)
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v *alertmanagertypes.Receiver) error {
ret := _mock.Called(context1, s, v)
if len(ret) == 0 {
panic("no return value specified for TestReceiver")
@@ -1707,7 +1633,7 @@ func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string,
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
r0 = returnFunc(context1, s, receiver)
r0 = returnFunc(context1, s, v)
} else {
r0 = ret.Error(0)
}
@@ -1722,12 +1648,12 @@ type MockAlertmanager_TestReceiver_Call struct {
// TestReceiver is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - receiver *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, receiver interface{}) *MockAlertmanager_TestReceiver_Call {
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, receiver)}
// - v *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_TestReceiver_Call {
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, v)}
}
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1755,7 +1681,7 @@ func (_c *MockAlertmanager_TestReceiver_Call) Return(err error) *MockAlertmanage
return _c
}
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
_c.Call.Return(run)
return _c
}
@@ -1824,8 +1750,8 @@ func (_c *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call) RunAndReturn(run
}
// UpdateChannelByReceiverAndID provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, receiver *alertmanagertypes.Receiver, uUID valuer.UUID) error {
ret := _mock.Called(context1, s, receiver, uUID)
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error {
ret := _mock.Called(context1, s, v, uUID)
if len(ret) == 0 {
panic("no return value specified for UpdateChannelByReceiverAndID")
@@ -1833,7 +1759,7 @@ func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Con
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error); ok {
r0 = returnFunc(context1, s, receiver, uUID)
r0 = returnFunc(context1, s, v, uUID)
} else {
r0 = ret.Error(0)
}
@@ -1848,13 +1774,13 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
// UpdateChannelByReceiverAndID is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - receiver *alertmanagertypes.Receiver
// - v *alertmanagertypes.Receiver
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, receiver interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, receiver, uUID)}
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, v interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, v, uUID)}
}
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1887,7 +1813,7 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Return(err error)
return _c
}
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
_c.Call.Return(run)
return _c
}
@@ -2039,52 +1965,6 @@ func (_c *MockHandler_CreateChannel_Call) RunAndReturn(run func(responseWriter h
return _c
}
// CreateNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) CreateNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_CreateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNotificationChannel'
type MockHandler_CreateNotificationChannel_Call struct {
*mock.Call
}
// CreateNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) CreateNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_CreateNotificationChannel_Call {
return &MockHandler_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", responseWriter, request)}
}
func (_c *MockHandler_CreateNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_CreateNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_CreateNotificationChannel_Call) Return() *MockHandler_CreateNotificationChannel_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_CreateNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_CreateNotificationChannel_Call {
_c.Run(run)
return _c
}
// CreateRoutePolicy provides a mock function for the type MockHandler
func (_mock *MockHandler) CreateRoutePolicy(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)

View File

@@ -19,8 +19,6 @@ type Handler interface {
DeleteChannelByID(http.ResponseWriter, *http.Request)
CreateNotificationChannel(http.ResponseWriter, *http.Request)
GetAllRoutePolicies(http.ResponseWriter, *http.Request)
GetRoutePolicyByID(http.ResponseWriter, *http.Request)

View File

@@ -1,43 +0,0 @@
package signozalertmanager
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
postable := new(alertmanagertypes.PostableNotificationChannel)
if err := binding.JSON.BindBody(req.Body, postable); err != nil {
render.Error(rw, err)
return
}
channel, err := handler.alertmanager.CreateNotificationChannel(ctx, claims.OrgID, postable)
if err != nil {
render.Error(rw, err)
return
}
gettable, err := channel.ToGettableNotificationChannel()
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, gettable)
}

View File

@@ -244,40 +244,6 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
return channel, nil
}
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
receiver, err := postable.ToReceiver()
if err != nil {
return nil, err
}
config, err := provider.configStore.Get(ctx, orgID)
if err != nil {
return nil, err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return nil, err
}
if err := config.CreateReceiverV2(receiver); err != nil {
return nil, err
}
channel, err := alertmanagertypes.NewChannelFromReceiverWithName(receiver, postable.Name, orgID)
if err != nil {
return nil, err
}
err = provider.configStore.CreateChannel(ctx, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
return provider.configStore.Set(ctx, config)
}))
if err != nil {
return nil, err
}
return channel, nil
}
func (provider *provider) Config() alertmanagerserver.Config {
return provider.config.Signoz.Config
}

View File

@@ -6,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/gorilla/mux"
)
@@ -131,33 +129,6 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/notification_channels", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.CreateNotificationChannel, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateNotificationChannel",
Tags: []string{"channels"},
Summary: "Create notification channel",
Description: "This endpoint creates a notification channel",
Request: new(alertmanagertypes.PostableNotificationChannel),
RequestContentType: "application/json",
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
ID: "GetAllRoutePolicies",
Tags: []string{"routepolicies"},

View File

@@ -33,12 +33,21 @@ type Licensing interface {
Delete(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) error
// Refresh refreshes the license state from upstream server
Refresh(ctx context.Context, organizationID valuer.UUID) error
// Checkout creates a checkout session via upstream server and returns the redirection link
Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error)
// Portal creates a portal session via upstream server and return the redirection link
Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error)
// GetFeatureFlags fetches all the defined feature flags
GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error)
statsreporter.StatsCollector
}
type API interface {
Checkout(http.ResponseWriter, *http.Request)
Portal(http.ResponseWriter, *http.Request)
}
type Handler interface {
Create(http.ResponseWriter, *http.Request)

View File

@@ -0,0 +1,23 @@
package nooplicensing
import (
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
)
type noopLicensingAPI struct{}
func NewLicenseAPI() licensing.API {
return &noopLicensingAPI{}
}
func (api *noopLicensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}
func (api *noopLicensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented"))
}

View File

@@ -59,6 +59,14 @@ func (provider *noopLicensing) Refresh(ctx context.Context, organizationID value
return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "refreshing license is not supported")
}
func (provider *noopLicensing) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "checkout session is not supported")
}
func (provider *noopLicensing) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "portal session is not supported")
}
func (provider *noopLicensing) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) {
return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching active license is not supported")
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
@@ -118,6 +119,8 @@ type APIHandler struct {
// Websocket connection upgrader
Upgrader *websocket.Upgrader
LicensingAPI licensing.API
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
@@ -136,6 +139,8 @@ type APIHandlerOpts struct {
// Flux Interval
FluxInterval time.Duration
LicensingAPI licensing.API
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
@@ -171,6 +176,7 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
LogsParsingPipelineController: opts.LogsParsingPipelineController,
querier: querier,
querierV2: querierv2,
LicensingAPI: opts.LicensingAPI,
Signoz: opts.Signoz,
QueryParserAPI: opts.QueryParserAPI,
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
@@ -83,6 +84,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
IntegrationsController: integrationsController,
LogsParsingPipelineController: logParsingPipelineController,
FluxInterval: config.Querier.FluxInterval,
LicensingAPI: nooplicensing.NewLicenseAPI(),
Signoz: signoz,
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
}, config)

View File

@@ -1,106 +0,0 @@
package alertmanagertypes
import (
"bytes"
"encoding/json"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"k8s.io/apimachinery/pkg/util/validation"
)
// ════════════════════════════════════════════════════════════════════════
// Postable
// ════════════════════════════════════════════════════════════════════════
// Name is the immutable DNS1123 identity references will point at; DisplayName is the
// free-text label.
type PostableNotificationChannel struct {
Name string `json:"name"`
GenerateName bool `json:"generateName"`
DisplayName string `json:"displayName"`
Config ChannelConfig `json:"config" required:"true"`
}
func (p *PostableNotificationChannel) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableNotificationChannel
var tmp alias
if err := dec.Decode(&tmp); err != nil {
if errors.Ast(err, errors.TypeInvalidInput) {
return err
}
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "%s", err.Error())
}
*p = PostableNotificationChannel(tmp)
if !p.GenerateName && p.DisplayName == "" {
p.DisplayName = p.Name
}
if err := p.Validate(); err != nil {
return err
}
if p.GenerateName {
p.Name = generateChannelName(p.DisplayName)
}
return nil
}
func (p *PostableNotificationChannel) Validate() error {
if err := p.validateName(); err != nil {
return err
}
if p.DisplayName == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "displayName is required")
}
if p.Name == DefaultReceiverName || p.DisplayName == DefaultReceiverName {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name %q is reserved", DefaultReceiverName)
}
return p.Config.Validate()
}
func (p *PostableNotificationChannel) validateName() error {
if p.GenerateName {
if p.Name != "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name must be empty when generateName is true, got %q", p.Name)
}
if p.DisplayName == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "displayName is required when generateName is true")
}
return nil
}
if p.Name == "" {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name is required")
}
if errs := validation.IsDNS1123Label(p.Name); len(errs) > 0 {
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name %q is invalid: %s", p.Name, strings.Join(errs, "; "))
}
return nil
}
// ════════════════════════════════════════════════════════════════════════
// Gettable
// ════════════════════════════════════════════════════════════════════════
type GettableNotificationChannel struct {
Name string `json:"name" required:"true"`
DisplayName string `json:"displayName" required:"true"`
Config ChannelConfig `json:"config" required:"true"`
ID valuer.UUID `json:"id" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
}

View File

@@ -1,138 +0,0 @@
package alertmanagertypes
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
)
func TestPostableChannelUnmarshalJSONRejectsBadInput(t *testing.T) {
testCases := []struct {
description string
body string
}{
{
description: "empty kind",
body: `{"name":"x","config":{"spec":{"to":"a@b.c"}}}`,
},
{
description: "missing spec",
body: `{"name":"x","config":{"kind":"slack"}}`,
},
{
// A custom UnmarshalJSON receives raw bytes, so the request body's own
// DisallowUnknownFields never reaches inside config.
description: "unknown field alongside kind and spec",
body: `{"name":"x","config":{"kind":"slack","bogus":1,"spec":{"apiUrl":"https://a","channel":"#c","title":"slack title","text":"slack text"}}}`,
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
var postable PostableNotificationChannel
assert.Error(t, json.Unmarshal([]byte(testCase.body), &postable))
})
}
}
func TestPostableChannelValidate(t *testing.T) {
testCases := []struct {
description string
postable PostableNotificationChannel
}{
{
description: "webhook password without username",
postable: PostableNotificationChannel{
Name: "hook",
DisplayName: "hook",
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &ChannelWebhookConfig{URL: "https://a", Password: "p"}},
},
},
{
description: "jira with an unparseable reopen duration",
postable: PostableNotificationChannel{
Name: "jira",
DisplayName: "jira",
Config: ChannelConfig{Kind: ChannelKindJira, Spec: &ChannelJiraConfig{
Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Bug",
Email: "oncall@acme.com", APIToken: "api-token",
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"), Description: valuer.MustNewUnsetOrNonEmptyString("jira description"), ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("three days"),
}},
},
},
{
description: "nil spec",
postable: PostableNotificationChannel{
Name: "oncall",
DisplayName: "oncall",
Config: ChannelConfig{Kind: ChannelKindSlack},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Error(t, testCase.postable.Validate())
})
}
}
// A display name that slugifies to nothing still has to yield a DNS1123 label,
// because the generated name is what every other resource references.
func TestPostableChannelUnmarshalJSONGeneratesANameFromAnUnslugifiableDisplayName(t *testing.T) {
var postable PostableNotificationChannel
require.NoError(t, json.Unmarshal([]byte(`{"generateName":true,"displayName":"###","config":{"kind":"slack","spec":{"apiUrl":"https://a","channel":"#c","title":"slack title","text":"slack text"}}}`), &postable))
assert.Equal(t, "###", postable.DisplayName)
assert.Empty(t, validation.IsDNS1123Label(postable.Name))
}
// A GettableNotificationChannel must marshal to the PostableNotificationChannel shape plus the
// server-owned fields, so a client can read one and post it back.
func TestGettableChannelMarshalsAsPostablePlusServerFields(t *testing.T) {
gettable := GettableNotificationChannel{
Name: "oncall",
DisplayName: "#oncall",
Config: ChannelConfig{Kind: ChannelKindSlack, Spec: &ChannelSlackConfig{APIURL: "https://a", Channel: "#c", Title: valuer.MustNewUnsetOrNonEmptyString("slack title"), Text: valuer.MustNewUnsetOrNonEmptyString("slack text")}},
}
raw, err := json.Marshal(gettable)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
assert.ElementsMatch(t, []string{"name", "displayName", "config", "id", "createdAt", "updatedAt"}, channelKeysOf(decoded))
config, ok := decoded["config"].(map[string]any)
require.True(t, ok)
assert.ElementsMatch(t, []string{"kind", "spec"}, channelKeysOf(config))
assert.Equal(t, "slack", config["kind"])
}
// Only a caller assembling the struct can pair a kind with another kind's spec;
// a decoded config builds the spec from the kind. Left unchecked the conversion
// to a receiver, which switches on the spec's type, would write a channel of the
// spec's kind under the declared one.
func TestChannelConfigValidateRejectsSpecOfAnotherKind(t *testing.T) {
config := ChannelConfig{
Kind: ChannelKindSlack,
Spec: &ChannelEmailConfig{To: "team@example.com", HTML: valuer.MustNewUnsetOrNonEmptyString("<p>body</p>")},
}
err := config.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "does not match kind")
}
func channelKeysOf(m map[string]any) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
return keys
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,117 +0,0 @@
package alertmanagertypes
import (
"encoding/json"
"reflect"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/config"
)
// ════════════════════════════════════════════════════════════════════════
// API -> storage
// ════════════════════════════════════════════════════════════════════════
// ToReceiver hands the assembled receiver to newDefaultedReceiver, which is the
// only place upstream applies a notifier's defaults and validation — several
// integrations panic without them.
func (p *PostableNotificationChannel) ToReceiver() (*Receiver, error) {
spec, ok := p.Config.Spec.(ChannelSpec)
if !ok {
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
}
receiver, err := spec.toUndefaultedReceiver(p.DisplayName)
if err != nil {
return nil, err
}
return newDefaultedReceiver(receiver)
}
// ════════════════════════════════════════════════════════════════════════
// Storage -> API
// ════════════════════════════════════════════════════════════════════════
// toPostableNotificationChannel derives the kind from the config the receiver
// actually carries rather than from Channel.Type, so a row written with several
// notifier kinds is rejected instead of reported under whichever one
// receiverChannelType happened to pick.
func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel, error) {
receiver := &Receiver{Receiver: &config.Receiver{}}
if err := json.Unmarshal([]byte(c.Data), receiver); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
}
if total := countNotifierConfigs(receiver); total > 1 {
return nil, errors.NewInvalidInputf(
ErrCodeAlertmanagerChannelInvalid,
"channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total,
)
}
for _, channelKind := range channelKinds {
if channelKind.countConfigs(receiver) == 0 {
continue
}
spec, err := channelKind.extractSpec(c.DisplayName, receiver)
if err != nil {
return nil, err
}
return &PostableNotificationChannel{
Name: c.Name,
DisplayName: c.DisplayName,
Config: ChannelConfig{Kind: channelKind.kind, Spec: spec},
}, nil
}
return nil, errors.NewNotFoundf(
ErrCodeChannelUnsupportedKind,
"channel %q carries no supported notifier configuration", c.DisplayName,
)
}
// countNotifierConfigs totals every *_configs entry on the receiver, including
// notifier kinds no ChannelSpec models, so a row mixing a modelled kind with
// an unmodelled one is not mistaken for a single-notifier channel.
func countNotifierConfigs(receiver *Receiver) int {
return countConfigsFields(reflect.ValueOf(*receiver)) +
countConfigsFields(reflect.ValueOf(*receiver.Receiver))
}
func countConfigsFields(v reflect.Value) int {
t := v.Type()
total := 0
for i := 0; i < t.NumField(); i++ {
fieldVal := v.Field(i)
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
continue
}
if !receiverTypeRegex.MatchString(t.Field(i).Tag.Get("yaml")) {
continue
}
total += fieldVal.Len()
}
return total
}
func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel, error) {
postable, err := c.toPostableNotificationChannel()
if err != nil {
return nil, err
}
return &GettableNotificationChannel{
Name: postable.Name,
DisplayName: postable.DisplayName,
Config: postable.Config,
ID: c.ID,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}, nil
}

View File

@@ -1,544 +0,0 @@
package alertmanagertypes
import (
"reflect"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The spec types and the upstream configs they translate through carry the same
// field sets, and nothing but this couples them: a field missing from either
// direction of the mapping is silently dropped. Every field is set so no default
// can fill the gap and hide it, and the whole spec is compared so a dropped
// field fails rather than going unasserted. Webhook is covered by
// TestPostableChannelToReceiverRoundTripsWebhookAuthModes, whose auth modes are
// mutually exclusive and so cannot all be set at once.
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
sendResolved := true
testCases := []struct {
description string
kind ChannelKind
spec any
expectedRoundTrip any
}{
{
description: "slack",
kind: ChannelKindSlack,
spec: &ChannelSlackConfig{
SendResolved: &sendResolved,
APIURL: "https://hooks.slack.com/services/T/B/X",
Channel: "#alerts",
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
},
expectedRoundTrip: &ChannelSlackConfig{
SendResolved: &sendResolved,
APIURL: "https://hooks.slack.com/services/T/B/X",
Channel: "#alerts",
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
},
},
{
description: "email",
kind: ChannelKindEmail,
spec: &ChannelEmailConfig{
SendResolved: &sendResolved,
To: "team@example.com",
HTML: valuer.MustNewUnsetOrNonEmptyString("<p>email body</p>"),
Headers: map[string]string{"Subject": "email subject"},
},
expectedRoundTrip: &ChannelEmailConfig{
SendResolved: &sendResolved,
To: "team@example.com",
HTML: valuer.MustNewUnsetOrNonEmptyString("<p>email body</p>"),
Headers: map[string]string{"Subject": "email subject"},
},
},
{
description: "pagerduty",
kind: ChannelKindPagerduty,
spec: &ChannelPagerdutyConfig{
SendResolved: &sendResolved,
RoutingKey: "routing-key",
URL: "https://events.example.com/v2/enqueue",
Source: valuer.MustNewUnsetOrNonEmptyString("pagerduty source"),
Client: valuer.MustNewUnsetOrNonEmptyString("pagerduty client"),
ClientURL: valuer.MustNewUnsetOrNonEmptyString("https://client.example.com"),
Description: valuer.MustNewUnsetOrNonEmptyString("pagerduty description"),
Severity: "critical",
Component: "api",
Group: "platform",
Class: "deploy",
Details: map[string]string{"env": "prod"},
},
// Map-valued fields are merged with the notifier's defaults rather
// than replaced, so a read cannot tell the caller's entries from
// upstream's. Clients that diff a read against their own input
// (Terraform) see the extra keys.
expectedRoundTrip: &ChannelPagerdutyConfig{
SendResolved: &sendResolved,
RoutingKey: "routing-key",
URL: "https://events.example.com/v2/enqueue",
Source: valuer.MustNewUnsetOrNonEmptyString("pagerduty source"),
Client: valuer.MustNewUnsetOrNonEmptyString("pagerduty client"),
ClientURL: valuer.MustNewUnsetOrNonEmptyString("https://client.example.com"),
Description: valuer.MustNewUnsetOrNonEmptyString("pagerduty description"),
Severity: "critical",
Component: "api",
Group: "platform",
Class: "deploy",
Details: map[string]string{
"env": "prod",
"firing": "{{ .Alerts.Firing | toJson }}",
"num_firing": "{{ .Alerts.Firing | len }}",
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": "{{ .Alerts.Resolved | toJson }}",
},
},
},
{
description: "opsgenie",
kind: ChannelKindOpsgenie,
spec: &ChannelOpsgenieConfig{
SendResolved: &sendResolved,
APIKey: "api-key",
APIURL: "https://api.eu.opsgenie.com",
Message: valuer.MustNewUnsetOrNonEmptyString("opsgenie message"),
Description: valuer.MustNewUnsetOrNonEmptyString("opsgenie description"),
Source: valuer.MustNewUnsetOrNonEmptyString("opsgenie source"),
Priority: "P1",
Details: map[string]string{"env": "prod"},
},
expectedRoundTrip: &ChannelOpsgenieConfig{
SendResolved: &sendResolved,
APIKey: "api-key",
APIURL: "https://api.eu.opsgenie.com",
Message: valuer.MustNewUnsetOrNonEmptyString("opsgenie message"),
Description: valuer.MustNewUnsetOrNonEmptyString("opsgenie description"),
Source: valuer.MustNewUnsetOrNonEmptyString("opsgenie source"),
Priority: "P1",
Details: map[string]string{"env": "prod"},
},
},
{
description: "msteams",
kind: ChannelKindMSTeams,
spec: &ChannelMSTeamsConfig{
SendResolved: &sendResolved,
WebhookURL: "https://teams.example.com/hook",
Title: valuer.MustNewUnsetOrNonEmptyString("msteams title"),
Text: valuer.MustNewUnsetOrNonEmptyString("msteams text"),
},
expectedRoundTrip: &ChannelMSTeamsConfig{
SendResolved: &sendResolved,
WebhookURL: "https://teams.example.com/hook",
Title: valuer.MustNewUnsetOrNonEmptyString("msteams title"),
Text: valuer.MustNewUnsetOrNonEmptyString("msteams text"),
},
},
{
description: "googlechat",
kind: ChannelKindGoogleChat,
spec: &ChannelGoogleChatConfig{
SendResolved: &sendResolved,
WebhookURL: "https://chat.googleapis.com/v1/spaces/s/messages",
Title: valuer.MustNewUnsetOrNonEmptyString("googlechat title"),
Text: valuer.MustNewUnsetOrNonEmptyString("googlechat text"),
},
expectedRoundTrip: &ChannelGoogleChatConfig{
SendResolved: &sendResolved,
WebhookURL: "https://chat.googleapis.com/v1/spaces/s/messages",
Title: valuer.MustNewUnsetOrNonEmptyString("googlechat title"),
Text: valuer.MustNewUnsetOrNonEmptyString("googlechat text"),
},
},
{
description: "jira",
kind: ChannelKindJira,
spec: &ChannelJiraConfig{
SendResolved: &sendResolved,
Site: "https://acme.atlassian.net",
Project: "OPS",
IssueType: "Bug",
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"),
Description: valuer.MustNewUnsetOrNonEmptyString("jira description"),
Priority: "High",
Labels: []string{"signoz", "alert"},
ResolveTransition: "Done",
ReopenTransition: "Reopen",
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
WontFixResolution: "Won't Do",
CustomFields: map[string]any{"customfield_10010": "Ops"},
Email: "oncall@acme.com",
APIToken: "api-token",
},
expectedRoundTrip: &ChannelJiraConfig{
SendResolved: &sendResolved,
Site: "https://acme.atlassian.net",
Project: "OPS",
IssueType: "Bug",
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"),
Description: valuer.MustNewUnsetOrNonEmptyString("jira description"),
Priority: "High",
Labels: []string{"signoz", "alert"},
ResolveTransition: "Done",
ReopenTransition: "Reopen",
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
WontFixResolution: "Won't Do",
CustomFields: map[string]any{"customfield_10010": "Ops"},
Email: "oncall@acme.com",
APIToken: "api-token",
},
},
{
description: "jsmops",
kind: ChannelKindJSMOps,
spec: &ChannelJSMOpsConfig{
SendResolved: &sendResolved,
APIKey: "api-key",
Message: valuer.MustNewUnsetOrNonEmptyString("jsmops message"),
Description: valuer.MustNewUnsetOrNonEmptyString("jsmops description"),
Priority: "P1",
Tags: valuer.MustNewUnsetOrNonEmptyString("signoz,oncall"),
},
expectedRoundTrip: &ChannelJSMOpsConfig{
SendResolved: &sendResolved,
APIKey: "api-key",
Message: valuer.MustNewUnsetOrNonEmptyString("jsmops message"),
Description: valuer.MustNewUnsetOrNonEmptyString("jsmops description"),
Priority: "P1",
Tags: valuer.MustNewUnsetOrNonEmptyString("signoz,oncall"),
},
},
{
description: "incidentio",
kind: ChannelKindIncidentIO,
spec: &ChannelIncidentIOConfig{
SendResolved: &sendResolved,
URL: "https://api.incident.io/v2/alert_events/http/01ABC",
Token: "token",
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"),
Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
Metadata: map[string]string{"team": "platform"},
},
expectedRoundTrip: &ChannelIncidentIOConfig{
SendResolved: &sendResolved,
URL: "https://api.incident.io/v2/alert_events/http/01ABC",
Token: "token",
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"),
Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
Metadata: map[string]string{"team": "platform"},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
postable := PostableNotificationChannel{
Name: "channel",
DisplayName: "channel",
Config: ChannelConfig{Kind: testCase.kind, Spec: testCase.spec},
}
require.NoError(t, postable.Validate())
receiver, err := postable.ToReceiver()
require.NoError(t, err)
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
require.NoError(t, err)
roundTripped, err := channel.toPostableNotificationChannel()
require.NoError(t, err)
assert.Equal(t, postable.Name, roundTripped.Name)
assert.Equal(t, testCase.kind, roundTripped.Config.Kind)
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
})
}
}
// Email transport is not representable in the channel spec, and no credential
// may reach storage. stripEmailTransport blanks Smarthost rather than dropping
// it, so the key survives as an empty string.
func TestPostableChannelToReceiverOmitsEmailTransportCredentials(t *testing.T) {
postable := PostableNotificationChannel{
Name: "team",
DisplayName: "team",
Config: ChannelConfig{
Kind: ChannelKindEmail,
Spec: &ChannelEmailConfig{To: "team@example.com"},
},
}
receiver, err := postable.ToReceiver()
require.NoError(t, err)
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
require.NoError(t, err)
for _, credentialKey := range []string{"auth_username", "auth_password", "auth_secret", "tls_config"} {
assert.NotContains(t, channel.Data, credentialKey)
}
assert.Contains(t, channel.Data, `"smarthost":""`)
}
// The UI offers basic auth and bearer token for webhooks, so both must survive a
// round trip. The legacy API overloaded one password field for both.
func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
// The webhook notifier defaults send_resolved to true, so a spec that omits
// it reads back with that default rather than as unset.
sendResolved := config.DefaultWebhookConfig.VSendResolved
testCases := []struct {
description string
spec ChannelWebhookConfig
expectedInData string
expectedRoundTrip *ChannelWebhookConfig
}{
{
description: "basic auth",
spec: ChannelWebhookConfig{URL: "https://example.com/hook", Username: "u", Password: "p"},
expectedInData: `"basic_auth"`,
expectedRoundTrip: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://example.com/hook", Username: "u", Password: "p"},
},
{
description: "bearer token",
spec: ChannelWebhookConfig{URL: "https://example.com/hook", BearerToken: "tok"},
expectedInData: `"authorization"`,
expectedRoundTrip: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://example.com/hook", BearerToken: "tok"},
},
{
description: "no auth",
spec: ChannelWebhookConfig{URL: "https://example.com/hook"},
expectedInData: `"url"`,
expectedRoundTrip: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://example.com/hook"},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
postable := PostableNotificationChannel{
Name: "hook",
DisplayName: "hook",
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &testCase.spec},
}
require.NoError(t, postable.Validate())
receiver, err := postable.ToReceiver()
require.NoError(t, err)
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
require.NoError(t, err)
assert.Contains(t, channel.Data, testCase.expectedInData)
roundTripped, err := channel.toPostableNotificationChannel()
require.NoError(t, err)
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
})
}
}
// The SigNoz notifiers validate in their UnmarshalYAML, which ToReceiver reaches
// only through the defaulting round-trip. A spec that passes Validate can still
// be rejected there, and the request has to fail as invalid input rather than as
// an internal error.
func TestPostableChannelToReceiverReportsNotifierValidationAsInvalidInput(t *testing.T) {
postable := PostableNotificationChannel{
Name: "channel",
DisplayName: "channel",
Config: ChannelConfig{Kind: ChannelKindIncidentIO, Spec: &ChannelIncidentIOConfig{
URL: "https://api.incident.io/v2/incidents", Token: "token",
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"), Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
}},
}
require.NoError(t, postable.Validate())
_, err := postable.ToReceiver()
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "got %v", err)
}
// rejectUnsupportedHTTPConfig enumerates the fields it rejects, so one added
// upstream would pass unnoticed and be dropped on read. Pinning the counts turns
// a dependency bump into a failing test rather than silent data loss.
func TestRejectUnrepresentableHTTPConfigCoversEveryUpstreamMember(t *testing.T) {
assert.Equal(t, 10, reflect.TypeFor[commoncfg.HTTPClientConfig]().NumField())
assert.Equal(t, 5, reflect.TypeFor[commoncfg.ProxyConfig]().NumField())
}
func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
testCases := []struct {
description string
channel Channel
}{
{
description: "two notifier kinds in one channel",
channel: Channel{
DisplayName: "mixed",
Data: `{"name":"mixed","slack_configs":[{"channel":"#a"}],"email_configs":[{"to":"a@b.c"}]}`,
},
},
{
// Only the first would survive the read, and the second would be
// dropped on the next write.
description: "two configs of the same notifier kind",
channel: Channel{
DisplayName: "two-slacks",
Data: `{"name":"two-slacks","slack_configs":[{"channel":"#a"},{"channel":"#b"}]}`,
},
},
{
description: "no notifier configuration",
channel: Channel{
DisplayName: "empty",
Data: `{"name":"empty"}`,
},
},
{
description: "notifier kind outside the supported set",
channel: Channel{
DisplayName: "tg",
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
},
},
{
description: "legacy msteams v1 configs",
channel: Channel{
DisplayName: "old-teams",
Data: `{"name":"old-teams","msteams_configs":[{"webhook_url":"https://a"}]}`,
},
},
{
// Dropping these on read would unauthenticate the channel on the
// next write, so the read fails instead.
description: "webhook http_config beyond basic auth and bearer token",
channel: Channel{
DisplayName: "proxied",
Data: `{"name":"proxied","webhook_configs":[{"url":"https://a","http_config":{"proxy_url":"https://proxy","tls_config":{"insecure_skip_verify":true}}}]}`,
},
},
{
description: "a modelled notifier kind alongside an unmodelled one",
channel: Channel{
DisplayName: "slack-and-telegram",
Data: `{"name":"slack-and-telegram","slack_configs":[{"api_url":"https://a","channel":"#a"}],"telegram_configs":[{"chat_id":1,"bot_token":"t"}]}`,
},
},
{
// The spec models one config per kind, so the second would be lost.
description: "two configs of one notifier kind",
channel: Channel{
DisplayName: "two-slacks",
Data: `{"name":"two-slacks","slack_configs":[{"api_url":"https://a","channel":"#a"},{"api_url":"https://b","channel":"#b"}]}`,
},
},
{
// The spec carries the credentials but not the scheme, so any other
// scheme would be rewritten as Bearer on the next write.
description: "webhook authorization scheme other than bearer",
channel: Channel{
DisplayName: "token-auth",
Data: `{"name":"token-auth","webhook_configs":[{"url":"https://a","http_config":{"authorization":{"type":"Token","credentials":"abc"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "webhook credentials sourced from a file",
channel: Channel{
DisplayName: "file-auth",
Data: `{"name":"file-auth","webhook_configs":[{"url":"https://a","http_config":{"authorization":{"type":"Bearer","credentials_file":"/run/token"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "webhook basic auth password sourced from a file",
channel: Channel{
DisplayName: "file-password",
Data: `{"name":"file-password","webhook_configs":[{"url":"https://a","http_config":{"basic_auth":{"username":"u","password_file":"/run/pass"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "webhook inline tls material",
channel: Channel{
DisplayName: "inline-tls",
Data: `{"name":"inline-tls","webhook_configs":[{"url":"https://a","http_config":{"tls_config":{"ca":"---PEM---","min_version":"TLS12"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
// The upstream kinds lift nothing out of http_config, so any credential
// or transport setting stored there would be dropped on the next write.
description: "slack basic auth",
channel: Channel{
DisplayName: "slack-basic",
Data: `{"name":"slack-basic","slack_configs":[{"api_url":"https://a","channel":"#a","http_config":{"basic_auth":{"username":"u","password":"p"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "opsgenie proxy",
channel: Channel{
DisplayName: "og-proxy",
Data: `{"name":"og-proxy","opsgenie_configs":[{"api_key":"k","http_config":{"proxy_url":"https://proxy","follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "pagerduty authorization header",
channel: Channel{
DisplayName: "pd-bearer",
Data: `{"name":"pd-bearer","pagerduty_configs":[{"routing_key":"k","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "msteams inline tls material",
channel: Channel{
DisplayName: "teams-tls",
Data: `{"name":"teams-tls","msteamsv2_configs":[{"webhook_url":"https://a","http_config":{"tls_config":{"ca":"---PEM---"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "googlechat basic auth",
channel: Channel{
DisplayName: "chat-basic",
Data: `{"name":"chat-basic","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/A/messages","http_config":{"basic_auth":{"username":"u","password":"p"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
// ChannelJiraConfig lifts only basic auth out of http_config, because
// that is all Jira Cloud accepts.
description: "jira authorization header",
channel: Channel{
DisplayName: "jira-bearer",
Data: `{"name":"jira-bearer","jira_configs":[{"site":"https://acme.atlassian.net","project":"OPS","issue_type":"Bug","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
// JSM Ops and incident.io authenticate through their own spec fields,
// so their specs model no http_config credentials at all.
description: "jsmops basic auth",
channel: Channel{
DisplayName: "jsm-basic",
Data: `{"name":"jsm-basic","jsmops_configs":[{"api_key":"key","http_config":{"basic_auth":{"username":"u","password":"p"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
{
description: "incidentio authorization header",
channel: Channel{
DisplayName: "io-bearer",
Data: `{"name":"io-bearer","incidentio_configs":[{"url":"https://api.incident.io/v2/alert_events/http/01ABC","token":"t","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
_, err := testCase.channel.toPostableNotificationChannel()
assert.Error(t, err)
})
}
}

View File

@@ -334,32 +334,12 @@ func cloneReceiver(receiver *Receiver) (*Receiver, error) {
func (c *Config) CreateReceiver(receiver *Receiver) error {
// check that receiver name is not already used
if c.hasReceiver(receiver.Name) {
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigConflict, "the receiver name has to be unique, please choose a different name")
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == receiver.Name {
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigConflict, "the receiver name has to be unique, please choose a different name")
}
}
return c.createReceiver(receiver)
}
// CreateReceiverV2 differs from CreateReceiver only in reporting a name already in
// use as a conflict rather than as invalid input. The v2 create path is the sole
// caller: v1 create, NewConfigFromChannels and TestReceiver stay on CreateReceiver
// so their responses keep the status code clients already see.
func (c *Config) CreateReceiverV2(receiver *Receiver) error {
if c.hasReceiver(receiver.Name) {
return errors.Newf(errors.TypeAlreadyExists, ErrCodeAlertmanagerChannelAlreadyExists, "channel with display name %q already exists", receiver.Name)
}
return c.createReceiver(receiver)
}
func (c *Config) hasReceiver(name string) bool {
return slices.ContainsFunc(c.alertmanagerConfig.Receivers, func(existing config.Receiver) bool {
return existing.Name == name
})
}
func (c *Config) createReceiver(receiver *Receiver) error {
owned, err := cloneReceiver(receiver)
if err != nil {
return err

View File

@@ -41,10 +41,6 @@ func NewReceiver(input string) (*Receiver, error) {
return nil, err
}
return newDefaultedReceiver(receiver)
}
func newDefaultedReceiver(receiver *Receiver) (*Receiver, error) {
withDefaults, err := defaultedBaseReceiver(receiver.Receiver)
if err != nil {
return nil, err

View File

@@ -71,7 +71,7 @@ var (
ResourceMetaResourceFactorPassword = NewResourceMetaResource(KindFactorPassword)
ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription)
ResourceMetaResourceDeploymentHost = NewResourceMetaResource(KindDeploymentHost, VerbList, VerbUpdate)
ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs)
ResourceTelemetryResourceTraces = NewResourceTelemetryResource(KindTraces)

View File

@@ -0,0 +1,33 @@
package licensetypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
)
type GettableSubscription struct {
RedirectURL string `json:"redirectURL"`
}
type PostableSubscription struct {
SuccessURL string `json:"url"`
}
func (p *PostableSubscription) UnmarshalJSON(data []byte) error {
var postableSubscription struct {
SuccessURL string `json:"url"`
}
err := json.Unmarshal(data, &postableSubscription)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal payload")
}
if postableSubscription.SuccessURL == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "success url cannot be empty")
}
p.SuccessURL = postableSubscription.SuccessURL
return nil
}

View File

@@ -33,13 +33,6 @@ func MustNewUnsetOrNonEmptyString(val string) UnsetOrNonEmptyString {
return nonEmptyString
}
// UnsetIfEmpty reads a value back from a store, where an empty string is how
// unset is spelled. It is the only way to reach the zero value from a string, so
// it must never be used on caller input, which has to reject "" instead.
func UnsetIfEmpty(val string) UnsetOrNonEmptyString {
return UnsetOrNonEmptyString{val: val}
}
func (enum UnsetOrNonEmptyString) IsZero() bool {
return enum.val == ""
}
@@ -80,18 +73,16 @@ func (enum *UnsetOrNonEmptyString) Scan(val any) error {
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "unset_or_non_empty_string: (nil \"%T\")", enum)
}
if val == nil {
*enum = UnsetOrNonEmptyString{}
return nil
}
str, ok := val.(string)
if !ok {
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "unset_or_non_empty_string: (non-string \"%T\")", val)
}
// scan is run when reading stored data where we can assume "" means unset, so no errors on seeing "".
*enum = UnsetIfEmpty(str)
var err error
*enum, err = NewUnsetOrNonEmptyString(str)
if err != nil {
return err
}
return nil
}

View File

@@ -63,22 +63,14 @@ func TestUnsetOrNonEmptyStringMarshalJSON(t *testing.T) {
assert.JSONEq(t, `"Alert"`, string(raw))
}
// A store spells unset as an empty or null column, so scanning one is the unset
// case rather than a failure. Only a non-string column is an error.
func TestUnsetOrNonEmptyStringScanReadsAnEmptyColumnAsUnset(t *testing.T) {
var unsetOrNonEmpty UnsetOrNonEmptyString
func TestUnsetOrNonEmptyStringScanRejectsAnEmptyString(t *testing.T) {
var nonEmptyString UnsetOrNonEmptyString
require.NoError(t, unsetOrNonEmpty.Scan("oncall"))
assert.Equal(t, "oncall", unsetOrNonEmpty.StringValue())
require.NoError(t, nonEmptyString.Scan("oncall"))
assert.Equal(t, "oncall", nonEmptyString.StringValue())
require.NoError(t, unsetOrNonEmpty.Scan(""))
assert.True(t, unsetOrNonEmpty.IsZero())
require.NoError(t, unsetOrNonEmpty.Scan("oncall"))
require.NoError(t, unsetOrNonEmpty.Scan(nil))
assert.True(t, unsetOrNonEmpty.IsZero())
assert.Error(t, unsetOrNonEmpty.Scan(42))
assert.Error(t, nonEmptyString.Scan(""))
assert.Error(t, nonEmptyString.Scan(nil))
}
func TestUnsetOrNonEmptyStringUnmarshalTextRejectsAnEmptyString(t *testing.T) {
@@ -98,8 +90,3 @@ func TestUnsetOrNonEmptyStringUnmarshalParamRejectsAnEmptyString(t *testing.T) {
assert.Error(t, nonEmptyString.UnmarshalParam(""))
}
func TestUnsetIfEmpty(t *testing.T) {
assert.True(t, UnsetIfEmpty("").IsZero())
assert.Equal(t, MustNewUnsetOrNonEmptyString("oncall"), UnsetIfEmpty("oncall"))
}

View File

@@ -210,32 +210,6 @@ def create_notification_channel(
logger.error("Failed to delete channel: %s", {"channel_id": channel_id, "status": response.status_code, "response": response.text})
@pytest.fixture(name="cleanup_notification_channels", scope="function")
def cleanup_notification_channels(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> Callable[[], list]:
"""Yields a list to append channel IDs to; each is deleted on teardown.
Deletion goes through v1, which owns the same rows as v2.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
channel_ids = []
yield channel_ids
for channel_id in channel_ids:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if response.status_code != HTTPStatus.NO_CONTENT:
logger.error("Failed to delete channel: %s", {"channel_id": channel_id, "status": response.status_code, "response": response.text})
@pytest.fixture(name="create_webhook_notification_channel", scope="function")
def create_webhook_notification_channel(
signoz: types.SigNoz,

View File

@@ -1,438 +0,0 @@
import re
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
create_active_user,
)
TIMEOUT = 10
V2_BASE_URL = "/api/v2/notification_channels"
DNS1123_LABEL = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
_EDITOR_EMAIL = "editor+channelsv2@integration.test"
_VIEWER_EMAIL = "viewer+channelsv2@integration.test"
_PASSWORD = "password123Z$"
@pytest.mark.parametrize(
"kind,spec,assert_field,assert_value",
[
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "channel", "#alerts", id="slack"),
pytest.param("email", {"to": "oncall@integration.test", "html": "<p>{{ .CommonLabels.alertname }}</p>"}, "to", "oncall@integration.test", id="email"),
pytest.param("webhook", {"url": "https://webhook.test/hook", "username": "bob", "password": "s3cret"}, "username", "bob", id="webhook"),
pytest.param("pagerduty", {"routingKey": "pd-routing-key", "severity": "critical", "class": "db", "description": "{{ .CommonLabels.alertname }}"}, "severity", "critical", id="pagerduty"),
pytest.param("opsgenie", {"apiKey": "og-api-key", "message": "{{ .CommonLabels.alertname }}", "description": "{{ .CommonLabels.alertname }}", "priority": "P2"}, "priority", "P2", id="opsgenie"),
pytest.param("msteams", {"webhookUrl": "https://teams.test/webhook/abc", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "title", "Alert", id="msteams"),
# The google chat notifier only accepts https URLs on chat.googleapis.com.
pytest.param("googlechat", {"webhookUrl": "https://chat.googleapis.com/v1/spaces/A/messages?key=k&token=t", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "title", "Alert", id="googlechat"),
# The jira notifier only accepts Jira Cloud sites and basic auth.
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token", "summary": "Alert", "description": "{{ .CommonLabels.alertname }}", "customFields": {"customfield_10010": "Ops"}}, "project", "OPS", id="jira"),
pytest.param("jsmops", {"apiKey": "jsm-api-key", "message": "Alert", "description": "{{ .CommonLabels.alertname }}", "priority": "P2"}, "priority", "P2", id="jsmops"),
# The incident.io notifier only accepts an alert source's events URL.
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "incidentio-token", "title": "Alert", "description": "{{ .CommonLabels.alertname }}"}, "title", "Alert", id="incidentio"),
],
)
def test_create_returns_the_channel_for_every_kind( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
kind: str,
spec: dict,
assert_field: str,
assert_value: str,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"v2-{kind}-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": name, "displayName": f"Display {name}", "config": {"kind": kind, "spec": spec}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
assert created["name"] == name
assert created["displayName"] == f"Display {name}"
assert created["config"]["kind"] == kind
assert created["config"]["spec"][assert_field] == assert_value
assert created["createdAt"]
assert created["updatedAt"]
@pytest.mark.parametrize(
"kind,spec,expected_send_resolved",
[
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": "Alert", "text": "body"}, False, id="slack"),
pytest.param("email", {"to": "oncall@integration.test", "html": "<p>body</p>"}, False, id="email"),
pytest.param("webhook", {"url": "https://webhook.test/hook"}, True, id="webhook"),
pytest.param("pagerduty", {"routingKey": "pd-routing-key", "description": "body"}, True, id="pagerduty"),
pytest.param("opsgenie", {"apiKey": "og-api-key", "message": "subject", "description": "body", "priority": "P2"}, True, id="opsgenie"),
pytest.param("msteams", {"webhookUrl": "https://teams.test/webhook/abc", "title": "Alert", "text": "body"}, True, id="msteams"),
pytest.param("googlechat", {"webhookUrl": "https://chat.googleapis.com/v1/spaces/A/messages?key=k&token=t", "title": "Alert", "text": "body"}, False, id="googlechat"),
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token", "summary": "Alert", "description": "body"}, False, id="jira"),
pytest.param("jsmops", {"apiKey": "jsm-api-key", "message": "Alert", "description": "body"}, False, id="jsmops"),
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "incidentio-token", "title": "Alert", "description": "body"}, False, id="incidentio"),
],
)
def test_create_without_send_resolved_returns_the_notifier_default( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
kind: str,
spec: dict,
expected_send_resolved: bool,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"v2-sendresolved-{kind}-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": name, "config": {"kind": kind, "spec": spec}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
assert created["config"]["spec"]["sendResolved"] is expected_send_resolved
@pytest.mark.parametrize(
"kind,spec,template_fields",
[
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X"}, ["title", "text"], id="slack"),
pytest.param("email", {"to": "oncall@integration.test"}, ["html"], id="email"),
pytest.param("pagerduty", {"routingKey": "pd-routing-key"}, ["description"], id="pagerduty"),
pytest.param("opsgenie", {"apiKey": "og-api-key"}, ["message", "description"], id="opsgenie"),
pytest.param("msteams", {"webhookUrl": "https://teams.test/webhook/abc"}, ["title", "text"], id="msteams"),
pytest.param("googlechat", {"webhookUrl": "https://chat.googleapis.com/v1/spaces/A/messages?key=k&token=t"}, ["title", "text"], id="googlechat"),
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, ["summary", "description"], id="jira"),
pytest.param("jsmops", {"apiKey": "jsm-api-key"}, ["message", "description"], id="jsmops"),
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "incidentio-token"}, ["title", "description"], id="incidentio"),
],
)
def test_create_without_templates_returns_the_notifier_defaults( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
kind: str,
spec: dict,
template_fields: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"v2-templates-{kind}-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": name, "config": {"kind": kind, "spec": spec}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
for field in template_fields:
assert "{{" in created["config"]["spec"][field], f"{field} should come back carrying the notifier's default template"
def test_create_defaults_display_name_to_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"v2-nodisplay-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": name, "config": {"kind": "email", "spec": {"to": "nodisplay@integration.test", "html": "<p>body</p>"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
assert created["name"] == name
assert created["displayName"] == name
def test_create_with_generate_name_derives_a_dns1123_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
display_name = f"On Call Escalation {uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={
"generateName": True,
"displayName": display_name,
"config": {"kind": "email", "spec": {"to": "generated@integration.test", "html": "<p>body</p>"}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
assert created["displayName"] == display_name
assert DNS1123_LABEL.match(created["name"]), created["name"]
assert created["name"].startswith("on-call-escalation-")
assert created["name"] != display_name
def test_create_generates_a_distinct_name_for_the_same_display_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
display_name = f"Duplicate Display {uuid.uuid4().hex[:8]}"
names = []
for suffix in ("a", "b"):
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={
"generateName": True,
# The display name has to differ, because it is still the
# receiver name in the alertmanager config and must be unique.
"displayName": f"{display_name} {suffix}",
"config": {"kind": "email", "spec": {"to": f"{suffix}@integration.test", "html": "<p>body</p>"}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
names.append(created["name"])
assert names[0] != names[1]
def test_create_rejects_a_duplicate_name_with_conflict(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
name = f"v2-dupname-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": name, "displayName": f"{name} first", "config": {"kind": "email", "spec": {"to": "first@integration.test", "html": "<p>body</p>"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
cleanup_notification_channels.append(response.json()["data"]["id"])
# Same name, different display name: only the unique index on
# (org_id, name) can catch this one.
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": name, "displayName": f"{name} second", "config": {"kind": "email", "spec": {"to": "second@integration.test", "html": "<p>body</p>"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
def test_create_rejects_a_duplicate_display_name(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
display_name = f"v2-dupdisplay-{uuid.uuid4().hex[:8]}"
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": f"{display_name}-one", "displayName": display_name, "config": {"kind": "email", "spec": {"to": "one@integration.test", "html": "<p>body</p>"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
cleanup_notification_channels.append(response.json()["data"]["id"])
# The display name is the receiver name in the alertmanager config, which
# rejects duplicates before the row is ever written.
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": f"{display_name}-two", "displayName": display_name, "config": {"kind": "email", "spec": {"to": "two@integration.test", "html": "<p>body</p>"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
# Both v2 conflicts share a status and an error code, so only the message
# separates a clashing display name from a clashing name.
assert "display name" in response.text
@pytest.mark.parametrize(
"body",
[
pytest.param({"name": "Not_A_Label", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="name_not_dns1123_label"),
pytest.param({"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="no_name_and_no_generate_name"),
pytest.param({"name": "explicit", "generateName": True, "displayName": "Explicit", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="name_with_generate_name"),
pytest.param({"generateName": True, "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="generate_name_without_display_name"),
pytest.param({"name": "default-receiver", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="reserved_receiver_name"),
pytest.param({"name": "no-config"}, id="no_config"),
pytest.param({"name": "telegram-kind", "config": {"kind": "telegram", "spec": {"chatId": 1}}}, id="unmodelled_kind"),
pytest.param({"name": "slack-unknown-field", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#a", "text": "body", "iconEmoji": ":tada:"}}}, id="unknown_spec_field"),
pytest.param({"name": "slack-with-email-spec", "config": {"kind": "slack", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="spec_of_another_kind"),
pytest.param({"name": "extra-field", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}, "type": "email"}, id="unknown_envelope_field"),
pytest.param({"name": "webhook-both-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"}}}, id="webhook_basic_auth_with_bearer_token"),
pytest.param({"name": "webhook-half-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u"}}}, id="webhook_basic_auth_without_password"),
# The last three reach the notifier's own validation rather than the
# spec's, so they assert it still surfaces as a 400 through v2.
pytest.param({"name": "jira-server-site", "config": {"kind": "jira", "spec": {"site": "https://jira.acme.com", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body"}}}, id="jira_site_not_jira_cloud"),
pytest.param(
{"name": "jira-short-reopen", "config": {"kind": "jira", "spec": {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body", "reopenDuration": "30s"}}}, id="jira_reopen_duration_below_a_minute"
),
pytest.param({"name": "incidentio-bearer", "config": {"kind": "incidentio", "spec": {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "Bearer incidentio-token", "title": "Alert", "description": "body"}}}, id="incidentio_token_with_bearer_prefix"),
pytest.param({"name": "slack-empty-title", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": ""}}}, id="empty_string_on_a_defaulted_field"),
pytest.param({"name": "jsmops-empty-tags", "config": {"kind": "jsmops", "spec": {"apiKey": "jsm-api-key", "tags": ""}}}, id="empty_string_on_a_defaulted_signoz_field"),
pytest.param({"name": "jira-noncanonical-reopen", "config": {"kind": "jira", "spec": {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "reopenDuration": "72h"}}}, id="jira_reopen_duration_not_as_reported"),
pytest.param({"name": "email-lowercase-header", "config": {"kind": "email", "spec": {"to": "a@integration.test", "headers": {"subject": "Alert"}}}}, id="email_header_name_not_as_reported"),
],
)
def test_create_rejects_invalid_bodies(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
body: dict,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
@pytest.mark.parametrize(
"kind,spec",
[
pytest.param("slack", {"channel": "#alerts", "title": "Alert", "text": "body"}, id="slack_without_api_url"),
pytest.param("email", {"html": "<p>body</p>"}, id="email_without_to"),
pytest.param("webhook", {}, id="webhook_without_url"),
pytest.param("pagerduty", {"description": "body"}, id="pagerduty_without_routing_key"),
pytest.param("opsgenie", {"message": "subject", "description": "body"}, id="opsgenie_without_api_key"),
pytest.param("msteams", {"title": "Alert", "text": "body"}, id="msteams_without_webhook_url"),
pytest.param("googlechat", {"title": "Alert", "text": "body"}, id="googlechat_without_webhook_url"),
pytest.param("jira", {"project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, id="jira_without_site"),
pytest.param("jira", {"site": "https://acme.atlassian.net", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, id="jira_without_project"),
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, id="jira_without_issue_type"),
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "apiToken": "jira-api-token"}, id="jira_without_email"),
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test"}, id="jira_without_api_token"),
pytest.param("jsmops", {}, id="jsmops_without_api_key"),
pytest.param("incidentio", {"token": "incidentio-token"}, id="incidentio_without_url"),
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF"}, id="incidentio_without_token"),
],
)
def test_create_rejects_a_spec_missing_a_required_field(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
kind: str,
spec: dict,
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={"name": f"v2-missing-{uuid.uuid4().hex[:8]}", "config": {"kind": kind, "spec": spec}},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_create_accepts_an_opsgenie_channel_without_a_priority(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Nothing seeds priority, so v1 channels created without one hold an empty
# value; requiring it here would make those rows unsaveable through v2.
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={
"name": f"v2-og-nopriority-{uuid.uuid4().hex[:8]}",
"config": {"kind": "opsgenie", "spec": {"apiKey": "og-api-key", "message": "subject", "description": "body"}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
assert created["config"]["spec"]["priority"] == ""
def test_create_echoes_an_empty_value_on_a_field_with_no_default(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
cleanup_notification_channels: list[str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(V2_BASE_URL),
json={
"name": f"v2-pd-empty-{uuid.uuid4().hex[:8]}",
"config": {"kind": "pagerduty", "spec": {"routingKey": "pd-routing-key", "severity": "", "class": ""}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=TIMEOUT,
)
assert response.status_code == HTTPStatus.CREATED, response.text
created = response.json()["data"]
cleanup_notification_channels.append(created["id"])
assert created["config"]["spec"]["severity"] == ""
assert created["config"]["spec"]["class"] == ""

View File

@@ -185,7 +185,7 @@ def test_license_checkout(
access_token = get_token("admin@integration.test", "password123Z$")
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v1/subscriptions"),
url=signoz.self.host_configs["8080"].get("/api/v1/checkout"),
json={"url": "https://integration-signoz.com"},
headers={"Authorization": "Bearer " + access_token},
timeout=5,
@@ -231,14 +231,14 @@ def test_license_portal(
access_token = get_token("admin@integration.test", "password123Z$")
response = requests.put(
url=signoz.self.host_configs["8080"].get("/api/v1/subscriptions"),
response = requests.post(
url=signoz.self.host_configs["8080"].get("/api/v1/portal"),
json={"url": "https://integration-signoz.com"},
headers={"Authorization": "Bearer " + access_token},
timeout=5,
)
assert response.status_code == http.HTTPStatus.OK
assert response.status_code == http.HTTPStatus.CREATED
assert response.json()["data"]["redirectURL"] == "https://signoz.portal.com"
response = requests.post(