Merge branch 'main' into fix/add-span-links-to-span-details

This commit is contained in:
Dimitris Mavrommatis 2025-05-27 21:25:51 +02:00 committed by GitHub
commit 91c8b0fa12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
303 changed files with 16213 additions and 5017 deletions

View File

@ -32,9 +32,7 @@ ex:
> Tag the relevant teams for review:
- [ ] @SigNoz/frontend
- [ ] @SigNoz/backend
- [ ] @SigNoz/devops
- frontend / backend / devops
---

View File

@ -67,9 +67,8 @@ jobs:
echo 'TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
echo 'TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
echo 'CUSTOMERIO_ID="${{ secrets.CUSTOMERIO_ID }}"' >> frontend/.env
echo 'CUSTOMERIO_SITE_ID="${{ secrets.CUSTOMERIO_SITE_ID }}"' >> frontend/.env
echo 'USERPILOT_KEY="${{ secrets.USERPILOT_KEY }}"' >> frontend/.env
echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@ -66,7 +66,8 @@ jobs:
echo 'CI=1' > frontend/.env
echo 'TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
echo 'TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'USERPILOT_KEY="${{ secrets.NP_USERPILOT_KEY }}"' >> frontend/.env
echo 'PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
echo 'APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@ -33,9 +33,8 @@ jobs:
echo 'TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
echo 'TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
echo 'CUSTOMERIO_ID="${{ secrets.CUSTOMERIO_ID }}"' >> .env
echo 'CUSTOMERIO_SITE_ID="${{ secrets.CUSTOMERIO_SITE_ID }}"' >> .env
echo 'USERPILOT_KEY="${{ secrets.USERPILOT_KEY }}"' >> .env
echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
- name: build-frontend
run: make js-build
- name: upload-frontend-artifact

View File

@ -1,4 +1,33 @@
linters:
default: standard
enable:
- bodyclose
- misspell
- nilnil
- sloglint
- depguard
- iface
linters-settings:
sloglint:
no-mixed-args: true
kv-only: true
no-global: all
context: all
static-msg: true
msg-style: lowercased
key-naming-case: snake
depguard:
rules:
nozap:
deny:
- pkg: "go.uber.org/zap"
desc: "Do not use zap logger. Use slog instead."
iface:
enable:
- identical
issues:
exclude-dirs:
- "pkg/query-service"
- "ee/query-service"
- "scripts/"

26
ee/licensing/config.go Normal file
View File

@ -0,0 +1,26 @@
package licensing
import (
"fmt"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/licensing"
)
var (
config licensing.Config
once sync.Once
)
// initializes the licensing configuration
func Config(pollInterval time.Duration, failureThreshold int) licensing.Config {
once.Do(func() {
config = licensing.Config{PollInterval: pollInterval, FailureThreshold: failureThreshold}
if err := config.Validate(); err != nil {
panic(fmt.Errorf("invalid licensing config: %w", err))
}
})
return config
}

View File

@ -0,0 +1,168 @@
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) Activate(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.PostableLicense)
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
render.Error(rw, err)
return
}
err = api.licensing.Activate(r.Context(), orgID, req.Key)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusAccepted, nil)
}
func (api *licensingAPI) GetActive(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
}
license, err := api.licensing.GetActive(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
}
gettableLicense := licensetypes.NewGettableLicense(license.Data, license.Key)
render.Success(rw, http.StatusOK, gettableLicense)
}
func (api *licensingAPI) Refresh(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
}
err = api.licensing.Refresh(r.Context(), orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
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

@ -0,0 +1,280 @@
package httplicensing
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
type provider struct {
store licensetypes.Store
zeus zeus.Zeus
config licensing.Config
settings factory.ScopedProviderSettings
stopChan chan struct{}
}
func NewProviderFactory(store sqlstore.SQLStore, zeus zeus.Zeus) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, providerSettings factory.ProviderSettings, config licensing.Config) (licensing.Licensing, error) {
return New(ctx, providerSettings, config, store, zeus)
})
}
func New(ctx context.Context, ps factory.ProviderSettings, config licensing.Config, sqlstore sqlstore.SQLStore, zeus zeus.Zeus) (licensing.Licensing, error) {
settings := factory.NewScopedProviderSettings(ps, "github.com/SigNoz/signoz/ee/licensing/httplicensing")
licensestore := sqllicensingstore.New(sqlstore)
return &provider{store: licensestore, zeus: zeus, config: config, settings: settings, stopChan: make(chan struct{})}, nil
}
func (provider *provider) Start(ctx context.Context) error {
tick := time.NewTicker(provider.config.PollInterval)
defer tick.Stop()
err := provider.Validate(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", "error", err)
}
for {
select {
case <-provider.stopChan:
return nil
case <-tick.C:
err := provider.Validate(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to validate license from upstream server", "error", err)
}
}
}
}
func (provider *provider) Stop(ctx context.Context) error {
provider.settings.Logger().DebugContext(ctx, "license validation stopped")
close(provider.stopChan)
return nil
}
func (provider *provider) Validate(ctx context.Context) error {
organizations, err := provider.store.ListOrganizations(ctx)
if err != nil {
return err
}
for _, organizationID := range organizations {
err := provider.Refresh(ctx, organizationID)
if err != nil {
return err
}
}
if len(organizations) == 0 {
err = provider.InitFeatures(ctx, licensetypes.BasicPlan)
if err != nil {
return err
}
}
return nil
}
func (provider *provider) Activate(ctx context.Context, organizationID valuer.UUID, key string) error {
data, err := provider.zeus.GetLicense(ctx, key)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch license data with upstream server")
}
license, err := licensetypes.NewLicense(data, organizationID)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity")
}
storableLicense := licensetypes.NewStorableLicenseFromLicense(license)
err = provider.store.Create(ctx, storableLicense)
if err != nil {
return err
}
err = provider.InitFeatures(ctx, license.Features)
if err != nil {
return err
}
return nil
}
func (provider *provider) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) {
storableLicenses, err := provider.store.GetAll(ctx, organizationID)
if err != nil {
return nil, err
}
activeLicense, err := licensetypes.GetActiveLicenseFromStorableLicenses(storableLicenses, organizationID)
if err != nil {
return nil, err
}
return activeLicense, nil
}
func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUID) error {
activeLicense, err := provider.GetActive(ctx, organizationID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
provider.settings.Logger().ErrorContext(ctx, "license validation failed", "org_id", organizationID.StringValue())
return err
}
if err != nil && errors.Ast(err, errors.TypeNotFound) {
provider.settings.Logger().DebugContext(ctx, "no active license found, defaulting to basic plan", "org_id", organizationID.StringValue())
err = provider.InitFeatures(ctx, licensetypes.BasicPlan)
if err != nil {
return err
}
return nil
}
data, err := provider.zeus.GetLicense(ctx, activeLicense.Key)
if err != nil {
if time.Since(activeLicense.LastValidatedAt) > time.Duration(provider.config.FailureThreshold)*provider.config.PollInterval {
provider.settings.Logger().ErrorContext(ctx, "license validation failed for consecutive poll intervals, defaulting to basic plan", "failure_threshold", provider.config.FailureThreshold, "license_id", activeLicense.ID.StringValue(), "org_id", organizationID.StringValue())
err = provider.InitFeatures(ctx, licensetypes.BasicPlan)
if err != nil {
return err
}
return nil
}
return err
}
err = activeLicense.Update(data)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity from license data")
}
updatedStorableLicense := licensetypes.NewStorableLicenseFromLicense(activeLicense)
err = provider.store.Update(ctx, organizationID, updatedStorableLicense)
if err != nil {
return err
}
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 {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session")
}
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, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session")
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
// feature surrogate
func (provider *provider) CheckFeature(ctx context.Context, key string) error {
feature, err := provider.store.GetFeature(ctx, key)
if err != nil {
return err
}
if feature.Active {
return nil
}
return errors.Newf(errors.TypeUnsupported, licensing.ErrCodeFeatureUnavailable, "feature unavailable: %s", key)
}
func (provider *provider) GetFeatureFlag(ctx context.Context, key string) (*featuretypes.GettableFeature, error) {
featureStatus, err := provider.store.GetFeature(ctx, key)
if err != nil {
return nil, err
}
return &featuretypes.GettableFeature{
Name: featureStatus.Name,
Active: featureStatus.Active,
Usage: int64(featureStatus.Usage),
UsageLimit: int64(featureStatus.UsageLimit),
Route: featureStatus.Route,
}, nil
}
func (provider *provider) GetFeatureFlags(ctx context.Context) ([]*featuretypes.GettableFeature, error) {
storableFeatures, err := provider.store.GetAllFeatures(ctx)
if err != nil {
return nil, err
}
gettableFeatures := make([]*featuretypes.GettableFeature, len(storableFeatures))
for idx, gettableFeature := range storableFeatures {
gettableFeatures[idx] = &featuretypes.GettableFeature{
Name: gettableFeature.Name,
Active: gettableFeature.Active,
Usage: int64(gettableFeature.Usage),
UsageLimit: int64(gettableFeature.UsageLimit),
Route: gettableFeature.Route,
}
}
return gettableFeatures, nil
}
func (provider *provider) InitFeatures(ctx context.Context, features []*featuretypes.GettableFeature) error {
featureStatus := make([]*featuretypes.StorableFeature, len(features))
for i, f := range features {
featureStatus[i] = &featuretypes.StorableFeature{
Name: f.Name,
Active: f.Active,
Usage: int(f.Usage),
UsageLimit: int(f.UsageLimit),
Route: f.Route,
}
}
return provider.store.InitFeatures(ctx, featureStatus)
}
func (provider *provider) UpdateFeatureFlag(ctx context.Context, feature *featuretypes.GettableFeature) error {
return provider.store.UpdateFeature(ctx, &featuretypes.StorableFeature{
Name: feature.Name,
Active: feature.Active,
Usage: int(feature.Usage),
UsageLimit: int(feature.UsageLimit),
Route: feature.Route,
})
}

View File

@ -0,0 +1,186 @@
package sqllicensingstore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func New(sqlstore sqlstore.SQLStore) licensetypes.Store {
return &store{sqlstore}
}
func (store *store) Create(ctx context.Context, storableLicense *licensetypes.StorableLicense) error {
_, err := store.
sqlstore.
BunDB().
NewInsert().
Model(storableLicense).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "license with ID: %s already exists", storableLicense.ID)
}
return nil
}
func (store *store) Get(ctx context.Context, organizationID valuer.UUID, licenseID valuer.UUID) (*licensetypes.StorableLicense, error) {
storableLicense := new(licensetypes.StorableLicense)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(storableLicense).
Where("org_id = ?", organizationID).
Where("id = ?", licenseID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "license with ID: %s does not exist", licenseID)
}
return storableLicense, nil
}
func (store *store) GetAll(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.StorableLicense, error) {
storableLicenses := make([]*licensetypes.StorableLicense, 0)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(&storableLicenses).
Where("org_id = ?", organizationID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "licenses for organizationID: %s does not exists", organizationID)
}
return storableLicenses, nil
}
func (store *store) Update(ctx context.Context, organizationID valuer.UUID, storableLicense *licensetypes.StorableLicense) error {
_, err := store.
sqlstore.
BunDB().
NewUpdate().
Model(storableLicense).
WherePK().
Where("org_id = ?", organizationID).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to update license with ID: %s", storableLicense.ID)
}
return nil
}
func (store *store) ListOrganizations(ctx context.Context) ([]valuer.UUID, error) {
orgIDStrs := make([]string, 0)
err := store.sqlstore.
BunDB().
NewSelect().
Model(new(types.Organization)).
Column("id").
Scan(ctx, &orgIDStrs)
if err != nil {
return nil, err
}
orgIDs := make([]valuer.UUID, len(orgIDStrs))
for idx, orgIDStr := range orgIDStrs {
orgID, err := valuer.NewUUID(orgIDStr)
if err != nil {
return nil, err
}
orgIDs[idx] = orgID
}
return orgIDs, nil
}
func (store *store) CreateFeature(ctx context.Context, storableFeature *featuretypes.StorableFeature) error {
_, err := store.
sqlstore.
BunDB().
NewInsert().
Model(storableFeature).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "feature with name:%s already exists", storableFeature.Name)
}
return nil
}
func (store *store) GetFeature(ctx context.Context, key string) (*featuretypes.StorableFeature, error) {
storableFeature := new(featuretypes.StorableFeature)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(storableFeature).
Where("name = ?", key).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "feature with name:%s does not exist", key)
}
return storableFeature, nil
}
func (store *store) GetAllFeatures(ctx context.Context) ([]*featuretypes.StorableFeature, error) {
storableFeatures := make([]*featuretypes.StorableFeature, 0)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(&storableFeatures).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "features do not exist")
}
return storableFeatures, nil
}
func (store *store) InitFeatures(ctx context.Context, storableFeatures []*featuretypes.StorableFeature) error {
_, err := store.
sqlstore.
BunDB().
NewInsert().
Model(&storableFeatures).
On("CONFLICT (name) DO UPDATE").
Set("active = EXCLUDED.active").
Set("usage = EXCLUDED.usage").
Set("usage_limit = EXCLUDED.usage_limit").
Set("route = EXCLUDED.route").
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to initialise features")
}
return nil
}
func (store *store) UpdateFeature(ctx context.Context, storableFeature *featuretypes.StorableFeature) error {
_, err := store.
sqlstore.
BunDB().
NewUpdate().
Model(storableFeature).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to update feature with key: %s", storableFeature.Name)
}
return nil
}

View File

@ -1,416 +0,0 @@
package impluser
import (
"context"
"encoding/json"
"net/http"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
// EnterpriseHandler embeds the base handler implementation
type Handler struct {
user.Handler // Embed the base handler interface
module user.Module
}
func NewHandler(module user.Module) user.Handler {
baseHandler := impluser.NewHandler(module)
return &Handler{
Handler: baseHandler,
module: module,
}
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var req types.PostableLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(w, err)
return
}
if req.RefreshToken == "" {
// the EE handler wrapper passes the feature flag value in context
ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool)
if !ok {
render.Error(w, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability"))
return
}
if ssoAvailable {
_, err := h.module.CanUsePassword(ctx, req.Email)
if err != nil {
render.Error(w, err)
return
}
}
}
user, err := h.module.GetAuthenticatedUser(ctx, req.OrgID, req.Email, req.Password, req.RefreshToken)
if err != nil {
render.Error(w, err)
return
}
jwt, err := h.module.GetJWTForUser(ctx, user)
if err != nil {
render.Error(w, err)
return
}
gettableLoginResponse := &types.GettableLoginResponse{
GettableUserJwt: jwt,
UserID: user.ID.String(),
}
render.Success(w, http.StatusOK, gettableLoginResponse)
}
// Override only the methods you need with enterprise-specific implementations
func (h *Handler) LoginPrecheck(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// assume user is valid unless proven otherwise and assign default values for rest of the fields
email := r.URL.Query().Get("email")
sourceUrl := r.URL.Query().Get("ref")
orgID := r.URL.Query().Get("orgID")
resp, err := h.module.LoginPrecheck(ctx, orgID, email, sourceUrl)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, resp)
}
func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req := new(types.PostableAcceptInvite)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode user"))
return
}
// get invite object
invite, err := h.module.GetInviteByToken(ctx, req.InviteToken)
if err != nil {
render.Error(w, err)
return
}
orgDomain, err := h.module.GetAuthDomainByEmail(ctx, invite.Email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
render.Error(w, err)
return
}
precheckResp := &types.GettableLoginPrecheck{
SSO: false,
IsUser: false,
}
if invite.Name == "" && req.DisplayName != "" {
invite.Name = req.DisplayName
}
user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID)
if err != nil {
render.Error(w, err)
return
}
if orgDomain != nil && orgDomain.SsoEnabled {
// sso is enabled, create user and respond precheck data
err = h.module.CreateUser(ctx, user)
if err != nil {
render.Error(w, err)
return
}
// check if sso is enforced for the org
precheckResp, err = h.module.LoginPrecheck(ctx, invite.OrgID, user.Email, req.SourceURL)
if err != nil {
render.Error(w, err)
return
}
} else {
password, err := types.NewFactorPassword(req.Password)
if err != nil {
render.Error(w, err)
return
}
_, err = h.module.CreateUserWithPassword(ctx, user, password)
if err != nil {
render.Error(w, err)
return
}
precheckResp.IsUser = true
}
// delete the invite
if err := h.module.DeleteInvite(ctx, invite.OrgID, invite.ID); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, precheckResp)
}
func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
token := mux.Vars(r)["token"]
sourceUrl := r.URL.Query().Get("ref")
invite, err := h.module.GetInviteByToken(ctx, token)
if err != nil {
render.Error(w, err)
return
}
// precheck the user
precheckResp, err := h.module.LoginPrecheck(ctx, invite.OrgID, invite.Email, sourceUrl)
if err != nil {
render.Error(w, err)
return
}
gettableInvite := &types.GettableEEInvite{
GettableInvite: *invite,
PreCheck: precheckResp,
}
render.Success(w, http.StatusOK, gettableInvite)
}
func (h *Handler) CreateAPIKey(w 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(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7"))
return
}
userID, err := valuer.NewUUID(claims.UserID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7"))
return
}
req := new(types.PostableAPIKey)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
apiKey, err := types.NewStorableAPIKey(
req.Name,
userID,
req.Role,
req.ExpiresInDays,
)
if err != nil {
render.Error(w, err)
return
}
err = h.module.CreateAPIKey(ctx, apiKey)
if err != nil {
render.Error(w, err)
return
}
createdApiKey, err := h.module.GetAPIKey(ctx, orgID, apiKey.ID)
if err != nil {
render.Error(w, err)
return
}
// just corrected the status code, response is same,
render.Success(w, http.StatusCreated, createdApiKey)
}
func (h *Handler) ListAPIKeys(w 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(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7"))
return
}
apiKeys, err := h.module.ListAPIKeys(ctx, orgID)
if err != nil {
render.Error(w, err)
return
}
// for backward compatibility
if len(apiKeys) == 0 {
render.Success(w, http.StatusOK, []types.GettableAPIKey{})
return
}
result := make([]*types.GettableAPIKey, len(apiKeys))
for i, apiKey := range apiKeys {
result[i] = types.NewGettableAPIKeyFromStorableAPIKey(apiKey)
}
render.Success(w, http.StatusOK, result)
}
func (h *Handler) UpdateAPIKey(w 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(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7"))
return
}
userID, err := valuer.NewUUID(claims.UserID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7"))
return
}
req := types.StorableAPIKey{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.module.GetAPIKey(ctx, orgID, id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.module.GetUserByID(ctx, orgID.String(), existingAPIKey.UserID.String())
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
err = h.module.UpdateAPIKey(ctx, id, &req, userID)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
func (h *Handler) RevokeAPIKey(w 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(w, err)
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7"))
return
}
userID, err := valuer.NewUUID(claims.UserID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.module.GetAPIKey(ctx, orgID, id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.module.GetUserByID(ctx, orgID.String(), existingAPIKey.UserID.String())
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email)) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
if err := h.module.RevokeAPIKey(ctx, id, userID); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}

View File

@ -1,252 +0,0 @@
package impluser
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/user"
baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
// EnterpriseModule embeds the base module implementation
type Module struct {
user.Module // Embed the base module implementation
store types.UserStore
}
func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module {
baseModule := baseimpl.NewModule(store, jwt, emailing, providerSettings)
return &Module{
Module: baseModule,
store: store,
}
}
func (m *Module) createUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) {
// get auth domain from email domain
_, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// get name from email
parts := strings.Split(email, "@")
if len(parts) < 2 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format")
}
name := parts[0]
defaultOrgID, err := m.store.GetDefaultOrgID(ctx)
if err != nil {
return nil, err
}
user, err := types.NewUser(name, email, types.RoleViewer.String(), defaultOrgID)
if err != nil {
return nil, err
}
err = m.CreateUser(ctx, user)
if err != nil {
return nil, err
}
return user, nil
}
func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) {
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
zap.L().Error("failed to get user with email received from auth provider", zap.String("error", err.Error()))
return "", err
}
user := &types.User{}
if len(users) == 0 {
newUser, err := m.createUserForSAMLRequest(ctx, email)
user = newUser
if err != nil {
zap.L().Error("failed to create user with email received from auth provider", zap.Error(err))
return "", err
}
} else {
user = &users[0].User
}
tokenStore, err := m.GetJWTForUser(ctx, user)
if err != nil {
zap.L().Error("failed to generate token for SSO login user", zap.Error(err))
return "", err
}
return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s",
redirectUri,
tokenStore.AccessJwt,
user.ID,
tokenStore.RefreshJwt), nil
}
func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) {
domain, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return false, err
}
if domain != nil && domain.SsoEnabled {
// sso is enabled, check if the user has admin role
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
return false, err
}
if len(users) == 0 {
return false, errors.New(errors.TypeNotFound, errors.CodeNotFound, "user not found")
}
if users[0].Role != types.RoleAdmin.String() {
return false, errors.New(errors.TypeForbidden, errors.CodeForbidden, "auth method not supported")
}
}
return true, nil
}
func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) {
resp := &types.GettableLoginPrecheck{IsUser: true, CanSelfRegister: false}
// check if email is a valid user
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
if len(users) == 0 {
resp.IsUser = false
}
// give them an option to select an org
if orgID == "" && len(users) > 1 {
resp.SelectOrg = true
resp.Orgs = make([]string, len(users))
for i, user := range users {
resp.Orgs[i] = user.OrgID
}
return resp, nil
}
// select the user with the corresponding orgID
if len(users) > 1 {
found := false
for _, tuser := range users {
if tuser.OrgID == orgID {
// user = tuser
found = true
break
}
}
if !found {
resp.IsUser = false
return resp, nil
}
}
// the EE handler wrapper passes the feature flag value in context
ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool)
if !ok {
zap.L().Error("failed to retrieve ssoAvailable from context")
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability")
}
if ssoAvailable {
// TODO(Nitya): in multitenancy this should use orgId as well.
orgDomain, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if orgDomain != nil && orgDomain.SsoEnabled {
// this is to allow self registration
resp.IsUser = true
// saml is enabled for this domain, lets prepare sso url
if sourceUrl == "" {
sourceUrl = constants.GetDefaultSiteURL()
}
// parse source url that generated the login request
var err error
escapedUrl, _ := url.QueryUnescape(sourceUrl)
siteUrl, err := url.Parse(escapedUrl)
if err != nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse referer")
}
// build Idp URL that will authenticat the user
// the front-end will redirect user to this url
resp.SSOUrl, err = orgDomain.BuildSsoUrl(siteUrl)
if err != nil {
zap.L().Error("failed to prepare saml request for domain", zap.String("domain", orgDomain.Name), zap.Error(err))
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to prepare saml request for domain")
}
// set SSO to true, as the url is generated correctly
resp.SSO = true
}
}
return resp, nil
}
func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) {
if email == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
components := strings.Split(email, "@")
if len(components) < 2 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format")
}
domain, err := m.store.GetDomainByName(ctx, components[1])
if err != nil {
return nil, err
}
gettableDomain := &types.GettableOrgDomain{StorableOrgDomain: *domain}
if err := gettableDomain.LoadConfig(domain.Data); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to load domain config")
}
return gettableDomain, nil
}
func (m *Module) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error {
return m.store.CreateAPIKey(ctx, apiKey)
}
func (m *Module) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error {
return m.store.UpdateAPIKey(ctx, id, apiKey, updaterID)
}
func (m *Module) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) {
return m.store.ListAPIKeys(ctx, orgID)
}
func (m *Module) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) {
return m.store.GetAPIKey(ctx, orgID, id)
}
func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error {
return m.store.RevokeAPIKey(ctx, id, removedByUserID)
}

View File

@ -1,37 +0,0 @@
package impluser
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
)
type store struct {
*baseimpl.Store
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) types.UserStore {
baseStore := baseimpl.NewStore(sqlstore).(*baseimpl.Store)
return &store{
Store: baseStore,
sqlstore: sqlstore,
}
}
func (s *store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) {
domain := new(types.StorableOrgDomain)
err := s.sqlstore.BunDB().NewSelect().
Model(domain).
Where("name = ?", name).
Limit(1).
Scan(ctx)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeNotFound, errors.CodeNotFound, "failed to get domain from name")
}
return domain, nil
}

View File

@ -6,42 +6,30 @@ import (
"net/http/httputil"
"time"
"github.com/SigNoz/signoz/ee/query-service/dao"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/interfaces"
"github.com/SigNoz/signoz/ee/query-service/license"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux"
"go.uber.org/zap"
)
type APIHandlerOptions struct {
DataConnector interfaces.DataConnector
PreferSpanMetrics bool
AppDao dao.ModelDao
RulesManager *rules.Manager
UsageManager *usage.Manager
FeatureFlags baseint.FeatureLookup
LicenseManager *license.Manager
IntegrationsController *integrations.Controller
CloudIntegrationsController *cloudintegrations.Controller
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
@ -61,22 +49,18 @@ type APIHandler struct {
// NewAPIHandler returns an APIHandler
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(signoz.SQLStore))
quickFilter := quickfilter.NewAPI(quickfiltermodule)
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
PreferSpanMetrics: opts.PreferSpanMetrics,
RuleManager: opts.RulesManager,
FeatureFlags: opts.FeatureFlags,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
FieldsAPI: fields.NewAPI(signoz.TelemetryStore),
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
FieldsAPI: fields.NewAPI(signoz.TelemetryStore, signoz.Instrumentation.Logger()),
Signoz: signoz,
QuickFilters: quickFilter,
QuickFilterModule: quickfiltermodule,
})
if err != nil {
@ -90,32 +74,20 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
return ah, nil
}
func (ah *APIHandler) FF() baseint.FeatureLookup {
return ah.opts.FeatureFlags
}
func (ah *APIHandler) RM() *rules.Manager {
return ah.opts.RulesManager
}
func (ah *APIHandler) LM() *license.Manager {
return ah.opts.LicenseManager
}
func (ah *APIHandler) UM() *usage.Manager {
return ah.opts.UsageManager
}
func (ah *APIHandler) AppDao() dao.ModelDao {
return ah.opts.AppDao
}
func (ah *APIHandler) Gateway() *httputil.ReverseProxy {
return ah.opts.Gateway
}
func (ah *APIHandler) CheckFeature(f string) bool {
err := ah.FF().CheckFeature(f)
func (ah *APIHandler) CheckFeature(ctx context.Context, key string) bool {
err := ah.Signoz.Licensing.CheckFeature(ctx, key)
return err == nil
}
@ -126,43 +98,29 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// routes available only in ee version
router.HandleFunc("/api/v1/featureFlags", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(ah.loginPrecheck)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(ah.Signoz.Handlers.User.LoginPrecheck)).Methods(http.MethodGet)
// invite
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.getInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(ah.acceptInvite)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.Signoz.Handlers.User.GetInvite)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(ah.Signoz.Handlers.User.AcceptInvite)).Methods(http.MethodPost)
// paid plans specific routes
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.receiveSAML)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/google", am.OpenAccess(ah.receiveGoogleAuth)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/{orgId}/domains", am.AdminAccess(ah.listDomainsByOrg)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/domains", am.AdminAccess(ah.postDomain)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.putDomain)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.deleteDomain)).Methods(http.MethodDelete)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/login", am.OpenAccess(ah.loginUser)).Methods(http.MethodPost)
// PAT APIs
router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.Signoz.Handlers.User.CreateAPIKey)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/pats", am.AdminAccess(ah.Signoz.Handlers.User.ListAPIKeys)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.Signoz.Handlers.User.UpdateAPIKey)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/pats/{id}", am.AdminAccess(ah.Signoz.Handlers.User.RevokeAPIKey)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.checkout)).Methods(http.MethodPost)
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.portalSession)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards/{uuid}/lock", am.EditAccess(ah.lockDashboard)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/dashboards/{uuid}/unlock", am.EditAccess(ah.unlockDashboard)).Methods(http.MethodPut)
// v3
router.HandleFunc("/api/v3/licenses", am.ViewAccess(ah.listLicensesV3)).Methods(http.MethodGet)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.applyLicenseV3)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.refreshLicensesV3)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.getActiveLicenseV3)).Methods(http.MethodGet)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)
router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(ah.LicensingAPI.GetActive)).Methods(http.MethodGet)
// v4
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
@ -174,54 +132,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
}
// TODO(nitya): remove this once we know how to get the FF's
func (ah *APIHandler) updateRequestContext(w http.ResponseWriter, r *http.Request) (*http.Request, error) {
ssoAvailable := true
err := ah.FF().CheckFeature(model.SSO)
if err != nil {
switch err.(type) {
case basemodel.ErrFeatureUnavailable:
// do nothing, just skip sso
ssoAvailable = false
default:
zap.L().Error("feature check failed", zap.String("featureKey", model.SSO), zap.Error(err))
return r, errors.New(errors.TypeInternal, errors.CodeInternal, "error checking SSO feature")
}
}
ctx := context.WithValue(r.Context(), types.SSOAvailable, ssoAvailable)
return r.WithContext(ctx), nil
}
func (ah *APIHandler) loginPrecheck(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.LoginPrecheck(w, r)
return
}
func (ah *APIHandler) acceptInvite(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.AcceptInvite(w, r)
return
}
func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.GetInvite(w, r)
return
}
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)

View File

@ -3,41 +3,18 @@ package api
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"go.uber.org/zap"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func parseRequest(r *http.Request, req interface{}) error {
defer r.Body.Close()
requestBody, err := io.ReadAll(r.Body)
if err != nil {
return err
}
err = json.Unmarshal(requestBody, &req)
return err
}
// loginUser overrides base handler and considers SSO case.
func (ah *APIHandler) loginUser(w http.ResponseWriter, r *http.Request) {
r, err := ah.updateRequestContext(w, r)
if err != nil {
render.Error(w, err)
return
}
ah.Signoz.Handlers.User.Login(w, r)
return
}
func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) {
ssoError := []byte("Login failed. Please contact your system administrator")
dst := make([]byte, base64.StdEncoding.EncodedLen(len(ssoError)))
@ -46,85 +23,31 @@ func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string)
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectURL, string(dst)), http.StatusSeeOther)
}
// receiveGoogleAuth completes google OAuth response and forwards a request
// to front-end to sign user in
func (ah *APIHandler) receiveGoogleAuth(w http.ResponseWriter, r *http.Request) {
redirectUri := constants.GetDefaultSiteURL()
ctx := context.Background()
if !ah.CheckFeature(model.SSO) {
zap.L().Error("[receiveGoogleAuth] sso requested but feature unavailable in org domain")
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "feature unavailable, please upgrade your billing plan to access this feature"), http.StatusMovedPermanently)
return
}
q := r.URL.Query()
if errType := q.Get("error"); errType != "" {
zap.L().Error("[receiveGoogleAuth] failed to login with google auth", zap.String("error", errType), zap.String("error_description", q.Get("error_description")))
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "failed to login through SSO "), http.StatusMovedPermanently)
return
}
relayState := q.Get("state")
zap.L().Debug("[receiveGoogleAuth] relay state received", zap.String("state", relayState))
parsedState, err := url.Parse(relayState)
if err != nil || relayState == "" {
zap.L().Error("[receiveGoogleAuth] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r))
handleSsoError(w, r, redirectUri)
return
}
// upgrade redirect url from the relay state for better accuracy
redirectUri = fmt.Sprintf("%s://%s%s", parsedState.Scheme, parsedState.Host, "/login")
// fetch domain by parsing relay state.
domain, err := ah.AppDao().GetDomainFromSsoResponse(ctx, parsedState)
if err != nil {
handleSsoError(w, r, redirectUri)
return
}
// now that we have domain, use domain to fetch sso settings.
// prepare google callback handler using parsedState -
// which contains redirect URL (front-end endpoint)
callbackHandler, err := domain.PrepareGoogleOAuthProvider(parsedState)
if err != nil {
zap.L().Error("[receiveGoogleAuth] failed to prepare google oauth provider", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
identity, err := callbackHandler.HandleCallback(r)
if err != nil {
zap.L().Error("[receiveGoogleAuth] failed to process HandleCallback ", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, identity.Email, ah.opts.JWT)
if err != nil {
zap.L().Error("[receiveGoogleAuth] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err))
handleSsoError(w, r, redirectUri)
return
}
http.Redirect(w, r, nextPage, http.StatusSeeOther)
}
// receiveSAML completes a SAML request and gets user logged in
func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
// this is the source url that initiated the login request
redirectUri := constants.GetDefaultSiteURL()
ctx := context.Background()
if !ah.CheckFeature(model.SSO) {
_, err = ah.Signoz.Licensing.GetActive(ctx, orgID)
if err != nil {
zap.L().Error("[receiveSAML] sso requested but feature unavailable in org domain")
http.Redirect(w, r, fmt.Sprintf("%s?ssoerror=%s", redirectUri, "feature unavailable, please upgrade your billing plan to access this feature"), http.StatusMovedPermanently)
return
}
err := r.ParseForm()
err = r.ParseForm()
if err != nil {
zap.L().Error("[receiveSAML] failed to process response - invalid response from IDP", zap.Error(err), zap.Any("request", r))
handleSsoError(w, r, redirectUri)
@ -147,7 +70,7 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) {
redirectUri = fmt.Sprintf("%s://%s%s", parsedState.Scheme, parsedState.Host, "/login")
// fetch domain by parsing relay state.
domain, err := ah.AppDao().GetDomainFromSsoResponse(ctx, parsedState)
domain, err := ah.Signoz.Modules.User.GetDomainFromSsoResponse(ctx, parsedState)
if err != nil {
handleSsoError(w, r, redirectUri)
return

View File

@ -36,6 +36,12 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
cloudProvider := mux.Vars(r)["cloudProvider"]
if cloudProvider != "aws" {
RespondError(w, basemodel.BadRequest(fmt.Errorf(
@ -56,11 +62,9 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
SigNozAPIKey: apiKey,
}
license, apiErr := ah.LM().GetRepo().GetActiveLicense(r.Context())
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't look for active license",
), nil)
license, err := ah.Signoz.Licensing.GetActive(r.Context(), orgID)
if err != nil {
render.Error(w, err)
return
}

View File

@ -1,91 +0,0 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
func (ah *APIHandler) listDomainsByOrg(w http.ResponseWriter, r *http.Request) {
orgId := mux.Vars(r)["orgId"]
domains, apierr := ah.AppDao().ListDomains(context.Background(), orgId)
if apierr != nil {
RespondError(w, apierr, domains)
return
}
ah.Respond(w, domains)
}
func (ah *APIHandler) postDomain(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
req := types.GettableOrgDomain{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
if err := req.ValidNew(); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
if apierr := ah.AppDao().CreateDomain(ctx, &req); apierr != nil {
RespondError(w, apierr, nil)
return
}
ah.Respond(w, &req)
}
func (ah *APIHandler) putDomain(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
domainIdStr := mux.Vars(r)["id"]
domainId, err := uuid.Parse(domainIdStr)
if err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
req := types.GettableOrgDomain{StorableOrgDomain: types.StorableOrgDomain{ID: domainId}}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
req.ID = domainId
if err := req.Valid(nil); err != nil {
RespondError(w, model.BadRequest(err), nil)
}
if apierr := ah.AppDao().UpdateDomain(ctx, &req); apierr != nil {
RespondError(w, apierr, nil)
return
}
ah.Respond(w, &req)
}
func (ah *APIHandler) deleteDomain(w http.ResponseWriter, r *http.Request) {
domainIdStr := mux.Vars(r)["id"]
domainId, err := uuid.Parse(domainIdStr)
if err != nil {
RespondError(w, model.BadRequest(fmt.Errorf("invalid domain id")), nil)
return
}
apierr := ah.AppDao().DeleteDomain(context.Background(), domainId)
if apierr != nil {
RespondError(w, apierr, nil)
return
}
ah.Respond(w, nil)
}

View File

@ -9,13 +9,29 @@ import (
"time"
"github.com/SigNoz/signoz/ee/query-service/constants"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
pkgError "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
featureSet, err := ah.FF().GetFeatureFlags()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, pkgError.Newf(pkgError.TypeInvalidInput, pkgError.CodeInvalidInput, "orgId is invalid"))
return
}
featureSet, err := ah.Signoz.Licensing.GetFeatureFlags(r.Context())
if err != nil {
ah.HandleError(w, err, http.StatusInternalServerError)
return
@ -23,7 +39,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
if constants.FetchFeatures == "true" {
zap.L().Debug("fetching license")
license, err := ah.LM().GetRepo().GetActiveLicense(ctx)
license, err := ah.Signoz.Licensing.GetActive(ctx, orgID)
if err != nil {
zap.L().Error("failed to fetch license", zap.Error(err))
} else if license == nil {
@ -44,9 +60,8 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
}
if ah.opts.PreferSpanMetrics {
for idx := range featureSet {
feature := &featureSet[idx]
if feature.Name == basemodel.UseSpanMetrics {
for idx, feature := range featureSet {
if feature.Name == featuretypes.UseSpanMetrics {
featureSet[idx].Active = true
}
}
@ -57,7 +72,7 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
// fetchZeusFeatures makes an HTTP GET request to the /zeusFeatures endpoint
// and returns the FeatureSet.
func fetchZeusFeatures(url, licenseKey string) (basemodel.FeatureSet, error) {
func fetchZeusFeatures(url, licenseKey string) ([]*featuretypes.GettableFeature, error) {
// Check if the URL is empty
if url == "" {
return nil, fmt.Errorf("url is empty")
@ -117,13 +132,13 @@ func fetchZeusFeatures(url, licenseKey string) (basemodel.FeatureSet, error) {
type ZeusFeaturesResponse struct {
Status string `json:"status"`
Data basemodel.FeatureSet `json:"data"`
Data []*featuretypes.GettableFeature `json:"data"`
}
// MergeFeatureSets merges two FeatureSet arrays with precedence to zeusFeatures.
func MergeFeatureSets(zeusFeatures, internalFeatures basemodel.FeatureSet) basemodel.FeatureSet {
func MergeFeatureSets(zeusFeatures, internalFeatures []*featuretypes.GettableFeature) []*featuretypes.GettableFeature {
// Create a map to store the merged features
featureMap := make(map[string]basemodel.Feature)
featureMap := make(map[string]*featuretypes.GettableFeature)
// Add all features from the otherFeatures set to the map
for _, feature := range internalFeatures {
@ -137,7 +152,7 @@ func MergeFeatureSets(zeusFeatures, internalFeatures basemodel.FeatureSet) basem
}
// Convert the map back to a FeatureSet slice
var mergedFeatures basemodel.FeatureSet
var mergedFeatures []*featuretypes.GettableFeature
for _, feature := range featureMap {
mergedFeatures = append(mergedFeatures, feature)
}

View File

@ -3,58 +3,58 @@ package api
import (
"testing"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/stretchr/testify/assert"
)
func TestMergeFeatureSets(t *testing.T) {
tests := []struct {
name string
zeusFeatures basemodel.FeatureSet
internalFeatures basemodel.FeatureSet
expected basemodel.FeatureSet
zeusFeatures []*featuretypes.GettableFeature
internalFeatures []*featuretypes.GettableFeature
expected []*featuretypes.GettableFeature
}{
{
name: "empty zeusFeatures and internalFeatures",
zeusFeatures: basemodel.FeatureSet{},
internalFeatures: basemodel.FeatureSet{},
expected: basemodel.FeatureSet{},
zeusFeatures: []*featuretypes.GettableFeature{},
internalFeatures: []*featuretypes.GettableFeature{},
expected: []*featuretypes.GettableFeature{},
},
{
name: "non-empty zeusFeatures and empty internalFeatures",
zeusFeatures: basemodel.FeatureSet{
zeusFeatures: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: false},
},
internalFeatures: basemodel.FeatureSet{},
expected: basemodel.FeatureSet{
internalFeatures: []*featuretypes.GettableFeature{},
expected: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: false},
},
},
{
name: "empty zeusFeatures and non-empty internalFeatures",
zeusFeatures: basemodel.FeatureSet{},
internalFeatures: basemodel.FeatureSet{
zeusFeatures: []*featuretypes.GettableFeature{},
internalFeatures: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: false},
},
expected: basemodel.FeatureSet{
expected: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: false},
},
},
{
name: "non-empty zeusFeatures and non-empty internalFeatures with no conflicts",
zeusFeatures: basemodel.FeatureSet{
zeusFeatures: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature3", Active: false},
},
internalFeatures: basemodel.FeatureSet{
internalFeatures: []*featuretypes.GettableFeature{
{Name: "Feature2", Active: true},
{Name: "Feature4", Active: false},
},
expected: basemodel.FeatureSet{
expected: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: true},
{Name: "Feature3", Active: false},
@ -63,15 +63,15 @@ func TestMergeFeatureSets(t *testing.T) {
},
{
name: "non-empty zeusFeatures and non-empty internalFeatures with conflicts",
zeusFeatures: basemodel.FeatureSet{
zeusFeatures: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: false},
},
internalFeatures: basemodel.FeatureSet{
internalFeatures: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: false},
{Name: "Feature3", Active: true},
},
expected: basemodel.FeatureSet{
expected: []*featuretypes.GettableFeature{
{Name: "Feature1", Active: true},
{Name: "Feature2", Active: false},
{Name: "Feature3", Active: true},

View File

@ -5,10 +5,26 @@ import (
"strings"
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (ah *APIHandler) ServeGatewayHTTP(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
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
}
validPath := false
for _, allowedPrefix := range gateway.AllowedPrefix {
if strings.HasPrefix(req.URL.Path, gateway.RoutePrefix+allowedPrefix) {
@ -22,9 +38,9 @@ func (ah *APIHandler) ServeGatewayHTTP(rw http.ResponseWriter, req *http.Request
return
}
license, err := ah.LM().GetRepo().GetActiveLicense(ctx)
license, err := ah.Signoz.Licensing.GetActive(ctx, orgID)
if err != nil {
RespondError(rw, err, nil)
render.Error(rw, err)
return
}

View File

@ -6,11 +6,7 @@ import (
"net/http"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/integrations/signozio"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type DayWiseBreakdown struct {
@ -49,10 +45,6 @@ type details struct {
BillTotal float64 `json:"billTotal"`
}
type Redirect struct {
RedirectURL string `json:"redirectURL"`
}
type billingDetails struct {
Status string `json:"status"`
Data struct {
@ -64,97 +56,6 @@ type billingDetails struct {
} `json:"data"`
}
type ApplyLicenseRequest struct {
LicenseKey string `json:"key"`
}
func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) {
ah.listLicensesV2(w, r)
}
func (ah *APIHandler) getActiveLicenseV3(w http.ResponseWriter, r *http.Request) {
activeLicense, err := ah.LM().GetRepo().GetActiveLicenseV3(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// return 404 not found if there is no active license
if activeLicense == nil {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("no active license found")}, nil)
return
}
// TODO deprecate this when we move away from key for stripe
activeLicense.Data["key"] = activeLicense.Key
render.Success(w, http.StatusOK, activeLicense.Data)
}
// this function is called by zeus when inserting licenses in the query-service
func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
var licenseKey ApplyLicenseRequest
if err := json.NewDecoder(r.Body).Decode(&licenseKey); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
if licenseKey.LicenseKey == "" {
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
return
}
_, err = ah.LM().ActivateV3(r.Context(), licenseKey.LicenseKey)
if err != nil {
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_ACT_FAILED, map[string]interface{}{"err": err.Error()}, claims.Email, true, false)
render.Error(w, err)
return
}
render.Success(w, http.StatusAccepted, nil)
}
func (ah *APIHandler) refreshLicensesV3(w http.ResponseWriter, r *http.Request) {
err := ah.LM().RefreshLicense(r.Context())
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
func getCheckoutPortalResponse(redirectURL string) *Redirect {
return &Redirect{RedirectURL: redirectURL}
}
func (ah *APIHandler) checkout(w http.ResponseWriter, r *http.Request) {
checkoutRequest := &model.CheckoutRequest{}
if err := json.NewDecoder(r.Body).Decode(checkoutRequest); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
license := ah.LM().GetActiveLicense()
if license == nil {
RespondError(w, model.BadRequestStr("cannot proceed with checkout without license key"), nil)
return
}
redirectUrl, err := signozio.CheckoutSession(r.Context(), checkoutRequest, license.Key, ah.Signoz.Zeus)
if err != nil {
render.Error(w, err)
return
}
ah.Respond(w, getCheckoutPortalResponse(redirectUrl))
}
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
licenseKey := r.URL.Query().Get("licenseKey")
@ -188,71 +89,3 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
// TODO(srikanthccv):Fetch the current day usage and add it to the response
ah.Respond(w, billingResponse.Data)
}
func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License {
licensesV2 := []model.License{}
for _, l := range licenses {
planKeyFromPlanName, ok := model.MapOldPlanKeyToNewPlanName[l.PlanName]
if !ok {
planKeyFromPlanName = model.Basic
}
licenseV2 := model.License{
Key: l.Key,
ActivationId: "",
PlanDetails: "",
FeatureSet: l.Features,
ValidationMessage: "",
IsCurrent: l.IsCurrent,
LicensePlan: model.LicensePlan{
PlanKey: planKeyFromPlanName,
ValidFrom: l.ValidFrom,
ValidUntil: l.ValidUntil,
Status: l.Status},
}
licensesV2 = append(licensesV2, licenseV2)
}
return licensesV2
}
func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) {
licensesV3, apierr := ah.LM().GetLicensesV3(r.Context())
if apierr != nil {
RespondError(w, apierr, nil)
return
}
licenses := convertLicenseV3ToLicenseV2(licensesV3)
resp := model.Licenses{
TrialStart: -1,
TrialEnd: -1,
OnTrial: false,
WorkSpaceBlock: false,
TrialConvertedToSubscription: false,
GracePeriodEnd: -1,
Licenses: licenses,
}
ah.Respond(w, resp)
}
func (ah *APIHandler) portalSession(w http.ResponseWriter, r *http.Request) {
portalRequest := &model.PortalRequest{}
if err := json.NewDecoder(r.Body).Decode(portalRequest); err != nil {
RespondError(w, model.BadRequest(err), nil)
return
}
license := ah.LM().GetActiveLicense()
if license == nil {
RespondError(w, model.BadRequestStr("cannot request the portal session without license key"), nil)
return
}
redirectUrl, err := signozio.PortalSession(r.Context(), portalRequest, license.Key, ah.Signoz.Zeus)
if err != nil {
render.Error(w, err)
return
}
ah.Respond(w, getCheckoutPortalResponse(redirectUrl))
}

View File

@ -11,13 +11,12 @@ import (
"github.com/gorilla/handlers"
"github.com/jmoiron/sqlx"
eemiddleware "github.com/SigNoz/signoz/ee/http/middleware"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/app/db"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/ee/query-service/dao/sqlite"
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/http/middleware"
@ -30,9 +29,6 @@ import (
"github.com/rs/cors"
"github.com/soheilhy/cmux"
licensepkg "github.com/SigNoz/signoz/ee/query-service/license"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
@ -90,18 +86,11 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
// NewServer creates and initializes Server
func NewServer(serverOptions *ServerOptions) (*Server, error) {
modelDao := sqlite.NewModelDao(serverOptions.SigNoz.SQLStore)
gatewayProxy, err := gateway.NewProxy(serverOptions.GatewayUrl, gateway.RoutePrefix)
if err != nil {
return nil, err
}
// initiate license manager
lm, err := licensepkg.StartManager(serverOptions.SigNoz.SQLStore.SQLxDB(), serverOptions.SigNoz.SQLStore, serverOptions.SigNoz.Zeus)
if err != nil {
return nil, err
}
fluxIntervalForTraceDetail, err := time.ParseDuration(serverOptions.FluxIntervalForTraceDetail)
if err != nil {
return nil, err
@ -168,11 +157,11 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
}
// start the usagemanager
usageManager, err := usage.New(modelDao, lm.GetRepo(), serverOptions.SigNoz.TelemetryStore.ClickhouseDB(), serverOptions.SigNoz.Zeus)
usageManager, err := usage.New(serverOptions.SigNoz.Licensing, serverOptions.SigNoz.TelemetryStore.ClickhouseDB(), serverOptions.SigNoz.Zeus, serverOptions.SigNoz.Modules.Organization)
if err != nil {
return nil, err
}
err = usageManager.Start()
err = usageManager.Start(context.Background())
if err != nil {
return nil, err
}
@ -194,11 +183,8 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
apiOpts := api.APIHandlerOptions{
DataConnector: reader,
PreferSpanMetrics: serverOptions.PreferSpanMetrics,
AppDao: modelDao,
RulesManager: rm,
UsageManager: usageManager,
FeatureFlags: lm,
LicenseManager: lm,
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
LogsParsingPipelineController: logParsingPipelineController,
@ -257,15 +243,15 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server,
r := baseapp.NewRouter()
r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap)
r.Use(eemiddleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap)
r.Use(middleware.NewTimeout(zap.L(),
r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap)
r.Use(middleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.serverOptions.SigNoz.Instrumentation.Logger(),
s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes,
s.serverOptions.Config.APIServer.Timeout.Default,
s.serverOptions.Config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAnalytics(zap.L()).Wrap)
r.Use(middleware.NewLogging(zap.L(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAnalytics().Wrap)
r.Use(middleware.NewLogging(s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap)
apiHandler.RegisterPrivateRoutes(r)
@ -289,15 +275,15 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger())
r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap)
r.Use(eemiddleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap)
r.Use(middleware.NewTimeout(zap.L(),
r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap)
r.Use(middleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.serverOptions.SigNoz.Instrumentation.Logger(),
s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes,
s.serverOptions.Config.APIServer.Timeout.Default,
s.serverOptions.Config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewAnalytics(zap.L()).Wrap)
r.Use(middleware.NewLogging(zap.L(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap)
r.Use(middleware.NewAnalytics().Wrap)
r.Use(middleware.NewLogging(s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap)
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
@ -431,15 +417,15 @@ func (s *Server) Start(ctx context.Context) error {
return nil
}
func (s *Server) Stop() error {
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
if err := s.httpServer.Shutdown(context.Background()); err != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
return err
}
}
if s.privateHTTP != nil {
if err := s.privateHTTP.Shutdown(context.Background()); err != nil {
if err := s.privateHTTP.Shutdown(ctx); err != nil {
return err
}
}
@ -447,11 +433,11 @@ func (s *Server) Stop() error {
s.opampServer.Stop()
if s.ruleManager != nil {
s.ruleManager.Stop(context.Background())
s.ruleManager.Stop(ctx)
}
// stop usage manager
s.usageManager.Stop()
s.usageManager.Stop(ctx)
return nil
}

View File

@ -4,10 +4,6 @@ import (
"os"
)
const (
DefaultSiteURL = "https://localhost:8080"
)
var LicenseSignozIo = "https://license.signoz.io/api/v1"
var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "")
var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
@ -24,12 +20,3 @@ func GetOrDefaultEnv(key string, fallback string) string {
}
return v
}
// constant functions that override env vars
// GetDefaultSiteURL returns default site url, primarily
// used to send saml request and allowing backend to
// handle http redirect
func GetDefaultSiteURL() string {
return GetOrDefaultEnv("SIGNOZ_SITE_URL", DefaultSiteURL)
}

View File

@ -1,23 +0,0 @@
package dao
import (
"context"
"net/url"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/google/uuid"
)
type ModelDao interface {
// auth methods
GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*types.GettableOrgDomain, error)
// org domain (auth domains) CRUD ops
ListDomains(ctx context.Context, orgId string) ([]types.GettableOrgDomain, basemodel.BaseApiError)
GetDomain(ctx context.Context, id uuid.UUID) (*types.GettableOrgDomain, basemodel.BaseApiError)
CreateDomain(ctx context.Context, d *types.GettableOrgDomain) basemodel.BaseApiError
UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError
DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError
GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError)
}

View File

@ -1,271 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/SigNoz/signoz/ee/query-service/model"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/google/uuid"
"go.uber.org/zap"
)
// GetDomainFromSsoResponse uses relay state received from IdP to fetch
// user domain. The domain is further used to process validity of the response.
// when sending login request to IdP we send relay state as URL (site url)
// with domainId or domainName as query parameter.
func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*types.GettableOrgDomain, error) {
// derive domain id from relay state now
var domainIdStr string
var domainNameStr string
var domain *types.GettableOrgDomain
for k, v := range relayState.Query() {
if k == "domainId" && len(v) > 0 {
domainIdStr = strings.Replace(v[0], ":", "-", -1)
}
if k == "domainName" && len(v) > 0 {
domainNameStr = v[0]
}
}
if domainIdStr != "" {
domainId, err := uuid.Parse(domainIdStr)
if err != nil {
zap.L().Error("failed to parse domainId from relay state", zap.Error(err))
return nil, fmt.Errorf("failed to parse domainId from IdP response")
}
domain, err = m.GetDomain(ctx, domainId)
if err != nil {
zap.L().Error("failed to find domain from domainId received in IdP response", zap.Error(err))
return nil, fmt.Errorf("invalid credentials")
}
}
if domainNameStr != "" {
domainFromDB, err := m.GetDomainByName(ctx, domainNameStr)
domain = domainFromDB
if err != nil {
zap.L().Error("failed to find domain from domainName received in IdP response", zap.Error(err))
return nil, fmt.Errorf("invalid credentials")
}
}
if domain != nil {
return domain, nil
}
return nil, fmt.Errorf("failed to find domain received in IdP response")
}
// GetDomainByName returns org domain for a given domain name
func (m *modelDao) GetDomainByName(ctx context.Context, name string) (*types.GettableOrgDomain, basemodel.BaseApiError) {
stored := types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
Model(&stored).
Where("name = ?", name).
Limit(1).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return nil, model.BadRequest(fmt.Errorf("invalid domain name"))
}
return nil, model.InternalError(err)
}
domain := &types.GettableOrgDomain{StorableOrgDomain: stored}
if err := domain.LoadConfig(stored.Data); err != nil {
return nil, model.InternalError(err)
}
return domain, nil
}
// GetDomain returns org domain for a given domain id
func (m *modelDao) GetDomain(ctx context.Context, id uuid.UUID) (*types.GettableOrgDomain, basemodel.BaseApiError) {
stored := types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
Model(&stored).
Where("id = ?", id).
Limit(1).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return nil, model.BadRequest(fmt.Errorf("invalid domain id"))
}
return nil, model.InternalError(err)
}
domain := &types.GettableOrgDomain{StorableOrgDomain: stored}
if err := domain.LoadConfig(stored.Data); err != nil {
return nil, model.InternalError(err)
}
return domain, nil
}
// ListDomains gets the list of auth domains by org id
func (m *modelDao) ListDomains(ctx context.Context, orgId string) ([]types.GettableOrgDomain, basemodel.BaseApiError) {
domains := []types.GettableOrgDomain{}
stored := []types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
Model(&stored).
Where("org_id = ?", orgId).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return domains, nil
}
return nil, model.InternalError(err)
}
for _, s := range stored {
domain := types.GettableOrgDomain{StorableOrgDomain: s}
if err := domain.LoadConfig(s.Data); err != nil {
zap.L().Error("ListDomains() failed", zap.Error(err))
}
domains = append(domains, domain)
}
return domains, nil
}
// CreateDomain creates a new auth domain
func (m *modelDao) CreateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError {
if domain.ID == uuid.Nil {
domain.ID = uuid.New()
}
if domain.OrgID == "" || domain.Name == "" {
return model.BadRequest(fmt.Errorf("domain creation failed, missing fields: OrgID, Name "))
}
configJson, err := json.Marshal(domain)
if err != nil {
zap.L().Error("failed to unmarshal domain config", zap.Error(err))
return model.InternalError(fmt.Errorf("domain creation failed"))
}
storableDomain := types.StorableOrgDomain{
ID: domain.ID,
Name: domain.Name,
OrgID: domain.OrgID,
Data: string(configJson),
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
}
_, err = m.sqlStore.BunDB().NewInsert().
Model(&storableDomain).
Exec(ctx)
if err != nil {
zap.L().Error("failed to insert domain in db", zap.Error(err))
return model.InternalError(fmt.Errorf("domain creation failed"))
}
return nil
}
// UpdateDomain updates stored config params for a domain
func (m *modelDao) UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) basemodel.BaseApiError {
if domain.ID == uuid.Nil {
zap.L().Error("domain update failed", zap.Error(fmt.Errorf("OrgDomain.Id is null")))
return model.InternalError(fmt.Errorf("domain update failed"))
}
configJson, err := json.Marshal(domain)
if err != nil {
zap.L().Error("domain update failed", zap.Error(err))
return model.InternalError(fmt.Errorf("domain update failed"))
}
storableDomain := &types.StorableOrgDomain{
ID: domain.ID,
Name: domain.Name,
OrgID: domain.OrgID,
Data: string(configJson),
TimeAuditable: types.TimeAuditable{UpdatedAt: time.Now()},
}
_, err = m.sqlStore.BunDB().NewUpdate().
Model(storableDomain).
Column("data", "updated_at").
WherePK().
Exec(ctx)
if err != nil {
zap.L().Error("domain update failed", zap.Error(err))
return model.InternalError(fmt.Errorf("domain update failed"))
}
return nil
}
// DeleteDomain deletes an org domain
func (m *modelDao) DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError {
if id == uuid.Nil {
zap.L().Error("domain delete failed", zap.Error(fmt.Errorf("OrgDomain.Id is null")))
return model.InternalError(fmt.Errorf("domain delete failed"))
}
storableDomain := &types.StorableOrgDomain{ID: id}
_, err := m.sqlStore.BunDB().NewDelete().
Model(storableDomain).
WherePK().
Exec(ctx)
if err != nil {
zap.L().Error("domain delete failed", zap.Error(err))
return model.InternalError(fmt.Errorf("domain delete failed"))
}
return nil
}
func (m *modelDao) GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError) {
if email == "" {
return nil, model.BadRequest(fmt.Errorf("could not find auth domain, missing fields: email "))
}
components := strings.Split(email, "@")
if len(components) < 2 {
return nil, model.BadRequest(fmt.Errorf("invalid email address"))
}
parsedDomain := components[1]
stored := types.StorableOrgDomain{}
err := m.sqlStore.BunDB().NewSelect().
Model(&stored).
Where("name = ?", parsedDomain).
Limit(1).
Scan(ctx)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, model.InternalError(err)
}
domain := &types.GettableOrgDomain{StorableOrgDomain: stored}
if err := domain.LoadConfig(stored.Data); err != nil {
return nil, model.InternalError(err)
}
return domain, nil
}

View File

@ -1,14 +0,0 @@
package sqlite
import (
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type modelDao struct {
sqlStore sqlstore.SQLStore
}
// InitDB creates and extends base model DB repository
func NewModelDao(sqlStore sqlstore.SQLStore) *modelDao {
return &modelDao{sqlStore: sqlStore}
}

View File

@ -1,67 +0,0 @@
package signozio
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
func ValidateLicenseV3(ctx context.Context, licenseKey string, zeus zeus.Zeus) (*model.LicenseV3, error) {
data, err := zeus.GetLicense(ctx, licenseKey)
if err != nil {
return nil, err
}
var m map[string]any
if err = json.Unmarshal(data, &m); err != nil {
return nil, err
}
license, err := model.NewLicenseV3(m)
if err != nil {
return nil, err
}
return license, nil
}
// SendUsage reports the usage of signoz to license server
func SendUsage(ctx context.Context, usage model.UsagePayload, zeus zeus.Zeus) error {
body, err := json.Marshal(usage)
if err != nil {
return err
}
return zeus.PutMeters(ctx, usage.LicenseKey.String(), body)
}
func CheckoutSession(ctx context.Context, checkoutRequest *model.CheckoutRequest, licenseKey string, zeus zeus.Zeus) (string, error) {
body, err := json.Marshal(checkoutRequest)
if err != nil {
return "", err
}
response, err := zeus.GetCheckoutURL(ctx, licenseKey, body)
if err != nil {
return "", err
}
return gjson.GetBytes(response, "url").String(), nil
}
func PortalSession(ctx context.Context, portalRequest *model.PortalRequest, licenseKey string, zeus zeus.Zeus) (string, error) {
body, err := json.Marshal(portalRequest)
if err != nil {
return "", err
}
response, err := zeus.GetPortalURL(ctx, licenseKey, body)
if err != nil {
return "", err
}
return gjson.GetBytes(response, "url").String(), nil
}

View File

@ -1,248 +0,0 @@
package license
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"github.com/mattn/go-sqlite3"
"github.com/SigNoz/signoz/ee/query-service/model"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"go.uber.org/zap"
)
// Repo is license repo. stores license keys in a secured DB
type Repo struct {
db *sqlx.DB
store sqlstore.SQLStore
}
// NewLicenseRepo initiates a new license repo
func NewLicenseRepo(db *sqlx.DB, store sqlstore.SQLStore) Repo {
return Repo{
db: db,
store: store,
}
}
func (r *Repo) GetLicensesV3(ctx context.Context) ([]*model.LicenseV3, error) {
licensesData := []model.LicenseDB{}
licenseV3Data := []*model.LicenseV3{}
query := "SELECT id,key,data FROM licenses_v3"
err := r.db.Select(&licensesData, query)
if err != nil {
return nil, fmt.Errorf("failed to get licenses from db: %v", err)
}
for _, l := range licensesData {
var licenseData map[string]interface{}
err := json.Unmarshal([]byte(l.Data), &licenseData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err)
}
license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData)
if err != nil {
return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err)
}
licenseV3Data = append(licenseV3Data, license)
}
return licenseV3Data, nil
}
// GetActiveLicense fetches the latest active license from DB.
// If the license is not present, expect a nil license and a nil error in the output.
func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel.ApiError) {
activeLicenseV3, err := r.GetActiveLicenseV3(ctx)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err))
}
if activeLicenseV3 == nil {
return nil, nil
}
activeLicenseV2 := model.ConvertLicenseV3ToLicenseV2(activeLicenseV3)
return activeLicenseV2, nil
}
func (r *Repo) GetActiveLicenseV3(ctx context.Context) (*model.LicenseV3, error) {
var err error
licenses := []model.LicenseDB{}
query := "SELECT id,key,data FROM licenses_v3"
err = r.db.Select(&licenses, query)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err))
}
var active *model.LicenseV3
for _, l := range licenses {
var licenseData map[string]interface{}
err := json.Unmarshal([]byte(l.Data), &licenseData)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err)
}
license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData)
if err != nil {
return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err)
}
if active == nil &&
(license.ValidFrom != 0) &&
(license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) {
active = license
}
if active != nil &&
license.ValidFrom > active.ValidFrom &&
(license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) {
active = license
}
}
return active, nil
}
// InsertLicenseV3 inserts a new license v3 in db
func (r *Repo) InsertLicenseV3(ctx context.Context, l *model.LicenseV3) *model.ApiError {
query := `INSERT INTO licenses_v3 (id, key, data) VALUES ($1, $2, $3)`
// licsense is the entity of zeus so putting the entire license here without defining schema
licenseData, err := json.Marshal(l.Data)
if err != nil {
return &model.ApiError{Typ: basemodel.ErrorBadData, Err: err}
}
_, err = r.db.ExecContext(ctx,
query,
l.ID,
l.Key,
string(licenseData),
)
if err != nil {
if sqliteErr, ok := err.(sqlite3.Error); ok {
if sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
zap.L().Error("error in inserting license data: ", zap.Error(sqliteErr))
return &model.ApiError{Typ: model.ErrorConflict, Err: sqliteErr}
}
}
zap.L().Error("error in inserting license data: ", zap.Error(err))
return &model.ApiError{Typ: basemodel.ErrorExec, Err: err}
}
return nil
}
// UpdateLicenseV3 updates a new license v3 in db
func (r *Repo) UpdateLicenseV3(ctx context.Context, l *model.LicenseV3) error {
// the key and id for the license can't change so only update the data here!
query := `UPDATE licenses_v3 SET data=$1 WHERE id=$2;`
license, err := json.Marshal(l.Data)
if err != nil {
return fmt.Errorf("insert license failed: license marshal error")
}
_, err = r.db.ExecContext(ctx,
query,
license,
l.ID,
)
if err != nil {
zap.L().Error("error in updating license data: ", zap.Error(err))
return fmt.Errorf("failed to update license in db: %v", err)
}
return nil
}
func (r *Repo) CreateFeature(req *types.FeatureStatus) *basemodel.ApiError {
_, err := r.store.BunDB().NewInsert().
Model(req).
Exec(context.Background())
if err != nil {
return &basemodel.ApiError{Typ: basemodel.ErrorInternal, Err: err}
}
return nil
}
func (r *Repo) GetFeature(featureName string) (types.FeatureStatus, error) {
var feature types.FeatureStatus
err := r.store.BunDB().NewSelect().
Model(&feature).
Where("name = ?", featureName).
Scan(context.Background())
if err != nil {
return feature, err
}
if feature.Name == "" {
return feature, basemodel.ErrFeatureUnavailable{Key: featureName}
}
return feature, nil
}
func (r *Repo) GetAllFeatures() ([]basemodel.Feature, error) {
var feature []basemodel.Feature
err := r.db.Select(&feature,
`SELECT * FROM feature_status;`)
if err != nil {
return feature, err
}
return feature, nil
}
func (r *Repo) UpdateFeature(req types.FeatureStatus) error {
_, err := r.store.BunDB().NewUpdate().
Model(&req).
Where("name = ?", req.Name).
Exec(context.Background())
if err != nil {
return err
}
return nil
}
func (r *Repo) InitFeatures(req []types.FeatureStatus) error {
// get a feature by name, if it doesn't exist, create it. If it does exist, update it.
for _, feature := range req {
currentFeature, err := r.GetFeature(feature.Name)
if err != nil && err == sql.ErrNoRows {
err := r.CreateFeature(&feature)
if err != nil {
return err
}
continue
} else if err != nil {
return err
}
feature.Usage = int(currentFeature.Usage)
if feature.Usage >= feature.UsageLimit && feature.UsageLimit != -1 {
feature.Active = false
}
err = r.UpdateFeature(feature)
if err != nil {
return err
}
}
return nil
}

View File

@ -1,318 +0,0 @@
package license
import (
"context"
"sync/atomic"
"time"
"github.com/jmoiron/sqlx"
"sync"
baseconstants "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/zeus"
validate "github.com/SigNoz/signoz/ee/query-service/integrations/signozio"
"github.com/SigNoz/signoz/ee/query-service/model"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
"go.uber.org/zap"
)
var LM *Manager
// validate and update license every 24 hours
var validationFrequency = 24 * 60 * time.Minute
type Manager struct {
repo *Repo
zeus zeus.Zeus
mutex sync.Mutex
validatorRunning bool
// end the license validation, this is important to gracefully
// stopping validation and protect in-consistent updates
done chan struct{}
// terminated waits for the validate go routine to end
terminated chan struct{}
// last time the license was validated
lastValidated int64
// keep track of validation failure attempts
failedAttempts uint64
// keep track of active license and features
activeLicenseV3 *model.LicenseV3
activeFeatures basemodel.FeatureSet
}
func StartManager(db *sqlx.DB, store sqlstore.SQLStore, zeus zeus.Zeus, features ...basemodel.Feature) (*Manager, error) {
if LM != nil {
return LM, nil
}
repo := NewLicenseRepo(db, store)
m := &Manager{
repo: &repo,
zeus: zeus,
}
if err := m.start(features...); err != nil {
return m, err
}
LM = m
return m, nil
}
// start loads active license in memory and initiates validator
func (lm *Manager) start(features ...basemodel.Feature) error {
return lm.LoadActiveLicenseV3(features...)
}
func (lm *Manager) Stop() {
close(lm.done)
<-lm.terminated
}
func (lm *Manager) SetActiveV3(l *model.LicenseV3, features ...basemodel.Feature) {
lm.mutex.Lock()
defer lm.mutex.Unlock()
if l == nil {
return
}
lm.activeLicenseV3 = l
lm.activeFeatures = append(l.Features, features...)
// set default features
setDefaultFeatures(lm)
err := lm.InitFeatures(lm.activeFeatures)
if err != nil {
zap.L().Panic("Couldn't activate features", zap.Error(err))
}
if !lm.validatorRunning {
// we want to make sure only one validator runs,
// we already have lock() so good to go
lm.validatorRunning = true
go lm.ValidatorV3(context.Background())
}
}
func setDefaultFeatures(lm *Manager) {
lm.activeFeatures = append(lm.activeFeatures, baseconstants.DEFAULT_FEATURE_SET...)
}
func (lm *Manager) LoadActiveLicenseV3(features ...basemodel.Feature) error {
active, err := lm.repo.GetActiveLicenseV3(context.Background())
if err != nil {
return err
}
if active != nil {
lm.SetActiveV3(active, features...)
} else {
zap.L().Info("No active license found, defaulting to basic plan")
// if no active license is found, we default to basic(free) plan with all default features
lm.activeFeatures = model.BasicPlan
setDefaultFeatures(lm)
err := lm.InitFeatures(lm.activeFeatures)
if err != nil {
zap.L().Error("Couldn't initialize features", zap.Error(err))
return err
}
}
return nil
}
func (lm *Manager) GetLicensesV3(ctx context.Context) (response []*model.LicenseV3, apiError *model.ApiError) {
licenses, err := lm.repo.GetLicensesV3(ctx)
if err != nil {
return nil, model.InternalError(err)
}
for _, l := range licenses {
if lm.activeLicenseV3 != nil && l.Key == lm.activeLicenseV3.Key {
l.IsCurrent = true
}
if l.ValidUntil == -1 {
// for subscriptions, there is no end-date as such
// but for showing user some validity we default one year timespan
l.ValidUntil = l.ValidFrom + 31556926
}
response = append(response, l)
}
return response, nil
}
// Validator validates license after an epoch of time
func (lm *Manager) ValidatorV3(ctx context.Context) {
zap.L().Info("ValidatorV3 started!")
defer close(lm.terminated)
tick := time.NewTicker(validationFrequency)
defer tick.Stop()
_ = lm.ValidateV3(ctx)
for {
select {
case <-lm.done:
return
default:
select {
case <-lm.done:
return
case <-tick.C:
_ = lm.ValidateV3(ctx)
}
}
}
}
func (lm *Manager) RefreshLicense(ctx context.Context) error {
license, err := validate.ValidateLicenseV3(ctx, lm.activeLicenseV3.Key, lm.zeus)
if err != nil {
return err
}
err = lm.repo.UpdateLicenseV3(ctx, license)
if err != nil {
return err
}
lm.SetActiveV3(license)
return nil
}
func (lm *Manager) ValidateV3(ctx context.Context) (reterr error) {
if lm.activeLicenseV3 == nil {
return nil
}
defer func() {
lm.mutex.Lock()
lm.lastValidated = time.Now().Unix()
if reterr != nil {
zap.L().Error("License validation completed with error", zap.Error(reterr))
atomic.AddUint64(&lm.failedAttempts, 1)
// default to basic plan if validation fails for three consecutive times
if atomic.LoadUint64(&lm.failedAttempts) > 3 {
zap.L().Error("License validation completed with error for three consecutive times, defaulting to basic plan", zap.String("license_id", lm.activeLicenseV3.ID), zap.Bool("license_validation", false))
lm.activeLicenseV3 = nil
lm.activeFeatures = model.BasicPlan
setDefaultFeatures(lm)
err := lm.InitFeatures(lm.activeFeatures)
if err != nil {
zap.L().Error("Couldn't initialize features", zap.Error(err))
}
lm.done <- struct{}{}
lm.validatorRunning = false
}
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_CHECK_FAILED,
map[string]interface{}{"err": reterr.Error()}, "", true, false)
} else {
// reset the failed attempts counter
atomic.StoreUint64(&lm.failedAttempts, 0)
zap.L().Info("License validation completed with no errors")
}
lm.mutex.Unlock()
}()
err := lm.RefreshLicense(ctx)
if err != nil {
return err
}
return nil
}
func (lm *Manager) ActivateV3(ctx context.Context, licenseKey string) (*model.LicenseV3, error) {
license, err := validate.ValidateLicenseV3(ctx, licenseKey, lm.zeus)
if err != nil {
return nil, err
}
// insert the new license to the sqlite db
modelErr := lm.repo.InsertLicenseV3(ctx, license)
if modelErr != nil {
zap.L().Error("failed to activate license", zap.Error(modelErr))
return nil, modelErr
}
// license is valid, activate it
lm.SetActiveV3(license)
return license, nil
}
func (lm *Manager) GetActiveLicense() *model.LicenseV3 {
return lm.activeLicenseV3
}
// CheckFeature will be internally used by backend routines
// for feature gating
func (lm *Manager) CheckFeature(featureKey string) error {
feature, err := lm.repo.GetFeature(featureKey)
if err != nil {
return err
}
if feature.Active {
return nil
}
return basemodel.ErrFeatureUnavailable{Key: featureKey}
}
// GetFeatureFlags returns current active features
func (lm *Manager) GetFeatureFlags() (basemodel.FeatureSet, error) {
return lm.repo.GetAllFeatures()
}
func (lm *Manager) InitFeatures(features basemodel.FeatureSet) error {
featureStatus := make([]types.FeatureStatus, len(features))
for i, f := range features {
featureStatus[i] = types.FeatureStatus{
Name: f.Name,
Active: f.Active,
Usage: int(f.Usage),
UsageLimit: int(f.UsageLimit),
Route: f.Route,
}
}
return lm.repo.InitFeatures(featureStatus)
}
func (lm *Manager) UpdateFeatureFlag(feature basemodel.Feature) error {
return lm.repo.UpdateFeature(types.FeatureStatus{
Name: feature.Name,
Active: feature.Active,
Usage: int(feature.Usage),
UsageLimit: int(feature.UsageLimit),
Route: feature.Route,
})
}
func (lm *Manager) GetFeatureFlag(key string) (basemodel.Feature, error) {
featureStatus, err := lm.repo.GetFeature(key)
if err != nil {
return basemodel.Feature{}, err
}
return basemodel.Feature{
Name: featureStatus.Name,
Active: featureStatus.Active,
Usage: int64(featureStatus.Usage),
UsageLimit: int64(featureStatus.UsageLimit),
Route: featureStatus.Route,
}, nil
}
// GetRepo return the license repo
func (lm *Manager) GetRepo() *Repo {
return lm.repo
}

View File

@ -6,7 +6,8 @@ import (
"os"
"time"
eeuserimpl "github.com/SigNoz/signoz/ee/modules/user/impluser"
"github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/ee/zeus"
@ -14,15 +15,15 @@ import (
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/envprovider"
"github.com/SigNoz/signoz/pkg/config/fileprovider"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/user"
pkglicensing "github.com/SigNoz/signoz/pkg/licensing"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
pkgzeus "github.com/SigNoz/signoz/pkg/zeus"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
@ -90,8 +91,9 @@ func main() {
loggerMgr := initZapLog()
zap.ReplaceGlobals(loggerMgr)
defer loggerMgr.Sync() // flushes buffer, if any
ctx := context.Background()
config, err := signoz.NewConfig(context.Background(), config.ResolverConfig{
config, err := signoz.NewConfig(ctx, config.ResolverConfig{
Uris: []string{"env:"},
ProviderFactories: []config.ProviderFactory{
envprovider.NewFactory(),
@ -127,19 +129,18 @@ func main() {
signoz, err := signoz.New(
context.Background(),
config,
jwt,
zeus.Config(),
httpzeus.NewProviderFactory(),
licensing.Config(24*time.Hour, 3),
func(sqlstore sqlstore.SQLStore, zeus pkgzeus.Zeus) factory.ProviderFactory[pkglicensing.Licensing, pkglicensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(),
sqlStoreFactories,
signoz.NewTelemetryStoreProviderFactories(),
func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module {
return eeuserimpl.NewModule(eeuserimpl.NewStore(sqlstore), jwt, emailing, providerSettings)
},
func(userModule user.Module) user.Handler {
return eeuserimpl.NewHandler(userModule)
},
)
if err != nil {
zap.L().Fatal("Failed to create signoz", zap.Error(err))
@ -163,22 +164,22 @@ func main() {
zap.L().Fatal("Failed to create server", zap.Error(err))
}
if err := server.Start(context.Background()); err != nil {
if err := server.Start(ctx); err != nil {
zap.L().Fatal("Could not start server", zap.Error(err))
}
signoz.Start(context.Background())
signoz.Start(ctx)
if err := signoz.Wait(context.Background()); err != nil {
if err := signoz.Wait(ctx); err != nil {
zap.L().Fatal("Failed to start signoz", zap.Error(err))
}
err = server.Stop()
err = server.Stop(ctx)
if err != nil {
zap.L().Fatal("Failed to stop server", zap.Error(err))
}
err = signoz.Stop(context.Background())
err = signoz.Stop(ctx)
if err != nil {
zap.L().Fatal("Failed to stop signoz", zap.Error(err))
}

View File

@ -1,244 +0,0 @@
package model
import (
"encoding/json"
"fmt"
"reflect"
"time"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/pkg/errors"
)
type License struct {
Key string `json:"key" db:"key"`
ActivationId string `json:"activationId" db:"activationId"`
CreatedAt time.Time `db:"created_at"`
// PlanDetails contains the encrypted plan info
PlanDetails string `json:"planDetails" db:"planDetails"`
// stores parsed license details
LicensePlan
FeatureSet basemodel.FeatureSet
// populated in case license has any errors
ValidationMessage string `db:"validationMessage"`
// used only for sending details to front-end
IsCurrent bool `json:"isCurrent"`
}
func (l *License) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct {
Key string `json:"key" db:"key"`
ActivationId string `json:"activationId" db:"activationId"`
ValidationMessage string `db:"validationMessage"`
IsCurrent bool `json:"isCurrent"`
PlanKey string `json:"planKey"`
ValidFrom time.Time `json:"ValidFrom"`
ValidUntil time.Time `json:"ValidUntil"`
Status string `json:"status"`
}{
Key: l.Key,
ActivationId: l.ActivationId,
IsCurrent: l.IsCurrent,
PlanKey: l.PlanKey,
ValidFrom: time.Unix(l.ValidFrom, 0),
ValidUntil: time.Unix(l.ValidUntil, 0),
Status: l.Status,
ValidationMessage: l.ValidationMessage,
})
}
type LicensePlan struct {
PlanKey string `json:"planKey"`
ValidFrom int64 `json:"validFrom"`
ValidUntil int64 `json:"validUntil"`
Status string `json:"status"`
}
type Licenses struct {
TrialStart int64 `json:"trialStart"`
TrialEnd int64 `json:"trialEnd"`
OnTrial bool `json:"onTrial"`
WorkSpaceBlock bool `json:"workSpaceBlock"`
TrialConvertedToSubscription bool `json:"trialConvertedToSubscription"`
GracePeriodEnd int64 `json:"gracePeriodEnd"`
Licenses []License `json:"licenses"`
}
type SubscriptionServerResp struct {
Status string `json:"status"`
Data Licenses `json:"data"`
}
type Plan struct {
Name string `json:"name"`
}
type LicenseDB struct {
ID string `json:"id"`
Key string `json:"key"`
Data string `json:"data"`
}
type LicenseV3 struct {
ID string
Key string
Data map[string]interface{}
PlanName string
Features basemodel.FeatureSet
Status string
IsCurrent bool
ValidFrom int64
ValidUntil int64
}
func extractKeyFromMapStringInterface[T any](data map[string]interface{}, key string) (T, error) {
var zeroValue T
if val, ok := data[key]; ok {
if value, ok := val.(T); ok {
return value, nil
}
return zeroValue, fmt.Errorf("%s key is not a valid %s", key, reflect.TypeOf(zeroValue))
}
return zeroValue, fmt.Errorf("%s key is missing", key)
}
func NewLicenseV3(data map[string]interface{}) (*LicenseV3, error) {
var features basemodel.FeatureSet
// extract id from data
licenseID, err := extractKeyFromMapStringInterface[string](data, "id")
if err != nil {
return nil, err
}
delete(data, "id")
// extract key from data
licenseKey, err := extractKeyFromMapStringInterface[string](data, "key")
if err != nil {
return nil, err
}
delete(data, "key")
// extract status from data
status, err := extractKeyFromMapStringInterface[string](data, "status")
if err != nil {
return nil, err
}
planMap, err := extractKeyFromMapStringInterface[map[string]any](data, "plan")
if err != nil {
return nil, err
}
planName, err := extractKeyFromMapStringInterface[string](planMap, "name")
if err != nil {
return nil, err
}
// if license status is invalid then default it to basic
if status == LicenseStatusInvalid {
planName = PlanNameBasic
}
featuresFromZeus := basemodel.FeatureSet{}
if _features, ok := data["features"]; ok {
featuresData, err := json.Marshal(_features)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal features data")
}
if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal features data")
}
}
switch planName {
case PlanNameEnterprise:
features = append(features, EnterprisePlan...)
case PlanNameBasic:
features = append(features, BasicPlan...)
default:
features = append(features, BasicPlan...)
}
if len(featuresFromZeus) > 0 {
for _, feature := range featuresFromZeus {
exists := false
for i, existingFeature := range features {
if existingFeature.Name == feature.Name {
features[i] = feature // Replace existing feature
exists = true
break
}
}
if !exists {
features = append(features, feature) // Append if it doesn't exist
}
}
}
data["features"] = features
_validFrom, err := extractKeyFromMapStringInterface[float64](data, "valid_from")
if err != nil {
_validFrom = 0
}
validFrom := int64(_validFrom)
_validUntil, err := extractKeyFromMapStringInterface[float64](data, "valid_until")
if err != nil {
_validUntil = 0
}
validUntil := int64(_validUntil)
return &LicenseV3{
ID: licenseID,
Key: licenseKey,
Data: data,
PlanName: planName,
Features: features,
ValidFrom: validFrom,
ValidUntil: validUntil,
Status: status,
}, nil
}
func NewLicenseV3WithIDAndKey(id string, key string, data map[string]interface{}) (*LicenseV3, error) {
licenseDataWithIdAndKey := data
licenseDataWithIdAndKey["id"] = id
licenseDataWithIdAndKey["key"] = key
return NewLicenseV3(licenseDataWithIdAndKey)
}
func ConvertLicenseV3ToLicenseV2(l *LicenseV3) *License {
planKeyFromPlanName, ok := MapOldPlanKeyToNewPlanName[l.PlanName]
if !ok {
planKeyFromPlanName = Basic
}
return &License{
Key: l.Key,
ActivationId: "",
PlanDetails: "",
FeatureSet: l.Features,
ValidationMessage: "",
IsCurrent: l.IsCurrent,
LicensePlan: LicensePlan{
PlanKey: planKeyFromPlanName,
ValidFrom: l.ValidFrom,
ValidUntil: l.ValidUntil,
Status: l.Status},
}
}
type CheckoutRequest struct {
SuccessURL string `json:"url"`
}
type PortalRequest struct {
SuccessURL string `json:"url"`
}

View File

@ -1,170 +0,0 @@
package model
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewLicenseV3(t *testing.T) {
testCases := []struct {
name string
data []byte
pass bool
expected *LicenseV3
error error
}{
{
name: "Error for missing license id",
data: []byte(`{}`),
pass: false,
error: errors.New("id key is missing"),
},
{
name: "Error for license id not being a valid string",
data: []byte(`{"id": 10}`),
pass: false,
error: errors.New("id key is not a valid string"),
},
{
name: "Error for missing license key",
data: []byte(`{"id":"does-not-matter"}`),
pass: false,
error: errors.New("key key is missing"),
},
{
name: "Error for invalid string license key",
data: []byte(`{"id":"does-not-matter","key":10}`),
pass: false,
error: errors.New("key key is not a valid string"),
},
{
name: "Error for missing license status",
data: []byte(`{"id":"does-not-matter", "key": "does-not-matter","category":"FREE"}`),
pass: false,
error: errors.New("status key is missing"),
},
{
name: "Error for invalid string license status",
data: []byte(`{"id":"does-not-matter","key": "does-not-matter", "category":"FREE", "status":10}`),
pass: false,
error: errors.New("status key is not a valid string"),
},
{
name: "Error for missing license plan",
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE"}`),
pass: false,
error: errors.New("plan key is missing"),
},
{
name: "Error for invalid json license plan",
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":10}`),
pass: false,
error: errors.New("plan key is not a valid map[string]interface {}"),
},
{
name: "Error for invalid license plan",
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{}}`),
pass: false,
error: errors.New("name key is missing"),
},
{
name: "Parse the entire license properly",
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`),
pass: true,
expected: &LicenseV3{
ID: "does-not-matter",
Key: "does-not-matter-key",
Data: map[string]interface{}{
"plan": map[string]interface{}{
"name": "ENTERPRISE",
},
"category": "FREE",
"status": "ACTIVE",
"valid_from": float64(1730899309),
"valid_until": float64(-1),
},
PlanName: PlanNameEnterprise,
ValidFrom: 1730899309,
ValidUntil: -1,
Status: "ACTIVE",
IsCurrent: false,
Features: model.FeatureSet{},
},
},
{
name: "Fallback to basic plan if license status is invalid",
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`),
pass: true,
expected: &LicenseV3{
ID: "does-not-matter",
Key: "does-not-matter-key",
Data: map[string]interface{}{
"plan": map[string]interface{}{
"name": "ENTERPRISE",
},
"category": "FREE",
"status": "INVALID",
"valid_from": float64(1730899309),
"valid_until": float64(-1),
},
PlanName: PlanNameBasic,
ValidFrom: 1730899309,
ValidUntil: -1,
Status: "INVALID",
IsCurrent: false,
Features: model.FeatureSet{},
},
},
{
name: "fallback states for validFrom and validUntil",
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from":1234.456,"valid_until":5678.567}`),
pass: true,
expected: &LicenseV3{
ID: "does-not-matter",
Key: "does-not-matter-key",
Data: map[string]interface{}{
"plan": map[string]interface{}{
"name": "ENTERPRISE",
},
"valid_from": 1234.456,
"valid_until": 5678.567,
"category": "FREE",
"status": "ACTIVE",
},
PlanName: PlanNameEnterprise,
ValidFrom: 1234,
ValidUntil: 5678,
Status: "ACTIVE",
IsCurrent: false,
Features: model.FeatureSet{},
},
},
}
for _, tc := range testCases {
var licensePayload map[string]interface{}
err := json.Unmarshal(tc.data, &licensePayload)
require.NoError(t, err)
license, err := NewLicenseV3(licensePayload)
if license != nil {
license.Features = make(model.FeatureSet, 0)
delete(license.Data, "features")
}
if tc.pass {
require.NoError(t, err)
require.NotNil(t, license)
assert.Equal(t, tc.expected, license)
} else {
require.Error(t, err)
assert.EqualError(t, err, tc.error.Error())
require.Nil(t, license)
}
}
}

View File

@ -14,9 +14,9 @@ import (
"go.uber.org/zap"
"github.com/SigNoz/signoz/ee/query-service/dao"
"github.com/SigNoz/signoz/ee/query-service/license"
"github.com/SigNoz/signoz/ee/query-service/model"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/query-service/utils/encryption"
"github.com/SigNoz/signoz/pkg/zeus"
)
@ -35,49 +35,54 @@ var (
type Manager struct {
clickhouseConn clickhouse.Conn
licenseRepo *license.Repo
licenseService licensing.Licensing
scheduler *gocron.Scheduler
modelDao dao.ModelDao
zeus zeus.Zeus
organizationModule organization.Module
}
func New(modelDao dao.ModelDao, licenseRepo *license.Repo, clickhouseConn clickhouse.Conn, zeus zeus.Zeus) (*Manager, error) {
func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, organizationModule organization.Module) (*Manager, error) {
m := &Manager{
clickhouseConn: clickhouseConn,
licenseRepo: licenseRepo,
licenseService: licenseService,
scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC
modelDao: modelDao,
zeus: zeus,
organizationModule: organizationModule,
}
return m, nil
}
// start loads collects and exports any exported snapshot and starts the exporter
func (lm *Manager) Start() error {
func (lm *Manager) Start(ctx context.Context) error {
// compares the locker and stateUnlocked if both are same lock is applied else returns error
if !atomic.CompareAndSwapUint32(&locker, stateUnlocked, stateLocked) {
return fmt.Errorf("usage exporter is locked")
}
_, err := lm.scheduler.Do(func() { lm.UploadUsage() })
// upload usage once when starting the service
_, err := lm.scheduler.Do(func() { lm.UploadUsage(ctx) })
if err != nil {
return err
}
// upload usage once when starting the service
lm.UploadUsage()
lm.UploadUsage(ctx)
lm.scheduler.StartAsync()
return nil
}
func (lm *Manager) UploadUsage() {
ctx := context.Background()
func (lm *Manager) UploadUsage(ctx context.Context) {
organizations, err := lm.organizationModule.GetAll(context.Background())
if err != nil {
zap.L().Error("failed to get organizations", zap.Error(err))
return
}
for _, organization := range organizations {
// check if license is present or not
license, err := lm.licenseRepo.GetActiveLicense(ctx)
license, err := lm.licenseService.GetActive(ctx, organization.ID)
if err != nil {
zap.L().Error("failed to get active license", zap.Error(err))
return
@ -169,14 +174,14 @@ func (lm *Manager) UploadUsage() {
// not returning error here since it is captured in the failed count
return
}
}
}
func (lm *Manager) Stop() {
func (lm *Manager) Stop(ctx context.Context) {
lm.scheduler.Stop()
zap.L().Info("sending usage data before shutting down")
// send usage before shutting down
lm.UploadUsage()
lm.UploadUsage(ctx)
atomic.StoreUint32(&locker, stateUnlocked)
}

View File

@ -36,8 +36,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
user,
isLoggedIn: isLoggedInState,
isFetchingOrgPreferences,
activeLicenseV3,
isFetchingActiveLicenseV3,
activeLicense,
isFetchingActiveLicense,
trialInfo,
featureFlags,
} = useAppContext();
@ -145,16 +145,16 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
};
useEffect(() => {
if (!isFetchingActiveLicenseV3 && activeLicenseV3) {
if (!isFetchingActiveLicense && activeLicense) {
const currentRoute = mapRoutes.get('current');
const isTerminated = activeLicenseV3.state === LicenseState.TERMINATED;
const isExpired = activeLicenseV3.state === LicenseState.EXPIRED;
const isCancelled = activeLicenseV3.state === LicenseState.CANCELLED;
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled;
const { platform } = activeLicenseV3;
const { platform } = activeLicense;
if (
isWorkspaceAccessRestricted &&
@ -164,26 +164,26 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
navigateToWorkSpaceAccessRestricted(currentRoute);
}
}
}, [isFetchingActiveLicenseV3, activeLicenseV3, mapRoutes, pathname]);
}, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]);
useEffect(() => {
if (!isFetchingActiveLicenseV3) {
if (!isFetchingActiveLicense) {
const currentRoute = mapRoutes.get('current');
const shouldBlockWorkspace = trialInfo?.workSpaceBlock;
if (
shouldBlockWorkspace &&
currentRoute &&
activeLicenseV3?.platform === LicensePlatform.CLOUD
activeLicense?.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceBlocked(currentRoute);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isFetchingActiveLicenseV3,
isFetchingActiveLicense,
trialInfo?.workSpaceBlock,
activeLicenseV3?.platform,
activeLicense?.platform,
mapRoutes,
pathname,
]);
@ -197,20 +197,20 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
};
useEffect(() => {
if (!isFetchingActiveLicenseV3 && activeLicenseV3) {
if (!isFetchingActiveLicense && activeLicense) {
const currentRoute = mapRoutes.get('current');
const shouldSuspendWorkspace =
activeLicenseV3.state === LicenseState.DEFAULTED;
activeLicense.state === LicenseState.DEFAULTED;
if (
shouldSuspendWorkspace &&
currentRoute &&
activeLicenseV3.platform === LicensePlatform.CLOUD
activeLicense.platform === LicensePlatform.CLOUD
) {
navigateToWorkSpaceSuspended(currentRoute);
}
}
}, [isFetchingActiveLicenseV3, activeLicenseV3, mapRoutes, pathname]);
}, [isFetchingActiveLicense, activeLicense, mapRoutes, pathname]);
useEffect(() => {
if (org && org.length > 0 && org[0].id !== undefined) {

View File

@ -13,9 +13,9 @@ import AppLayout from 'container/AppLayout';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { LICENSE_PLAN_KEY } from 'hooks/useLicense';
import { NotificationProvider } from 'hooks/useNotifications';
import { ResourceProvider } from 'hooks/useResourceAttribute';
import { StatusCodes } from 'http-status-codes';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
@ -23,6 +23,7 @@ import AlertRuleProvider from 'providers/Alert';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Router, Switch } from 'react-router-dom';
@ -41,14 +42,13 @@ import defaultRoutes, {
function App(): JSX.Element {
const themeConfig = useThemeConfig();
const {
licenses,
user,
isFetchingUser,
isFetchingLicenses,
isFetchingFeatureFlags,
trialInfo,
activeLicenseV3,
isFetchingActiveLicenseV3,
activeLicense,
isFetchingActiveLicense,
activeLicenseFetchError,
userFetchError,
featureFlagsFetchError,
isLoggedIn: isLoggedInState,
@ -66,7 +66,7 @@ function App(): JSX.Element {
const enableAnalytics = useCallback(
(user: IUser): void => {
// wait for the required data to be loaded before doing init for anything!
if (!isFetchingActiveLicenseV3 && activeLicenseV3 && org) {
if (!isFetchingActiveLicense && activeLicense && org) {
const orgName =
org && Array.isArray(org) && org.length > 0 ? org[0].displayName : '';
@ -153,8 +153,8 @@ function App(): JSX.Element {
},
[
hostname,
isFetchingActiveLicenseV3,
activeLicenseV3,
isFetchingActiveLicense,
activeLicense,
org,
trialInfo?.trialConvertedToSubscription,
],
@ -163,18 +163,17 @@ function App(): JSX.Element {
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => {
if (
!isFetchingLicenses &&
licenses &&
!isFetchingActiveLicense &&
(activeLicense || activeLicenseFetchError) &&
!isFetchingUser &&
user &&
!!user.email
) {
const isOnBasicPlan =
licenses.licenses?.some(
(license) =>
license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN,
) || licenses.licenses === null;
activeLicenseFetchError &&
[StatusCodes.NOT_FOUND, StatusCodes.NOT_IMPLEMENTED].includes(
activeLicenseFetchError?.getHttpStatusCode(),
);
const isIdentifiedUser = getLocalStorageApi(LOCALSTORAGE.IS_IDENTIFIED_USER);
if (isLoggedInState && user && user.id && user.email && !isIdentifiedUser) {
@ -204,11 +203,12 @@ function App(): JSX.Element {
}, [
isLoggedInState,
user,
licenses,
isCloudUser,
isEnterpriseSelfHostedUser,
isFetchingLicenses,
isFetchingActiveLicense,
isFetchingUser,
activeLicense,
activeLicenseFetchError,
]);
useEffect(() => {
@ -231,8 +231,7 @@ function App(): JSX.Element {
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
licenses &&
activeLicenseV3 &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
@ -270,8 +269,7 @@ function App(): JSX.Element {
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
licenses,
activeLicenseV3,
activeLicense,
trialInfo,
isCloudUser,
isEnterpriseSelfHostedUser,
@ -333,7 +331,7 @@ function App(): JSX.Element {
// if the user is in logged in state
if (isLoggedInState) {
// if the setup calls are loading then return a spinner
if (isFetchingLicenses || isFetchingUser || isFetchingFeatureFlags) {
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
return <Spinner tip="Loading..." />;
}
@ -345,7 +343,11 @@ function App(): JSX.Element {
}
// if all of the data is not set then return a spinner, this is required because there is some gap between loading states and data setting
if ((!licenses || !user.email || !featureFlags) && !userFetchError) {
if (
(!activeLicense || !user.email || !featureFlags) &&
!userFetchError &&
!activeLicenseFetchError
) {
return <Spinner tip="Loading..." />;
}
}
@ -357,6 +359,7 @@ function App(): JSX.Element {
<CompatRouter>
<UserpilotRouteTracker />
<NotificationProvider>
<ErrorModalProvider>
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
@ -385,6 +388,7 @@ function App(): JSX.Element {
</QueryBuilderProvider>
</ResourceProvider>
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</CompatRouter>
</Router>

View File

@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/SAML/deleteDomain';
const deleteDomain = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.delete(`/domains/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default deleteDomain;

View File

@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/SAML/listDomain';
const listAllDomain = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/orgs/${props.orgId}/domains`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default listAllDomain;

View File

@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/SAML/postDomain';
const postDomain = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post(`/domains`, props);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default postDomain;

View File

@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/SAML/updateDomain';
const updateDomain = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/domains/${props.id}`, props);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default updateDomain;

View File

@ -1,29 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
} from 'types/api/billing/checkout';
const updateCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponse<CheckoutSuccessPayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/checkout', {
url: props.url,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default updateCreditCardApi;

View File

@ -1,29 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
CheckoutRequestPayloadProps,
CheckoutSuccessPayloadProps,
} from 'types/api/billing/checkout';
const manageCreditCardApi = async (
props: CheckoutRequestPayloadProps,
): Promise<SuccessResponse<CheckoutSuccessPayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/portal', {
url: props.url,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default manageCreditCardApi;

View File

@ -4,7 +4,11 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import loginApi from 'api/v1/login/login';
import afterLogin from 'AppRoutes/utils';
import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import axios, {
AxiosError,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
@ -83,6 +87,7 @@ const interceptorRejected = async (
true,
);
try {
const reResponse = await axios(
`${value.config.baseURL}${value.config.url?.substring(1)}`,
{
@ -96,11 +101,13 @@ const interceptorRejected = async (
},
},
);
if (reResponse.status === 200) {
return await Promise.resolve(reResponse);
}
} catch (error) {
if ((error as AxiosError)?.response?.status === 401) {
Logout();
return await Promise.reject(reResponse);
}
}
} catch (error) {
Logout();
}

View File

@ -1,26 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/licenses/apply';
const apply = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/licenses', {
key: props.key,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default apply;

View File

@ -1,18 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps } from 'types/api/licenses/getAll';
const getAll = async (): Promise<
SuccessResponse<PayloadProps> | ErrorResponse
> => {
const response = await axios.get('/licenses');
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getAll;

View File

@ -1,18 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { LicenseV3EventQueueResModel } from 'types/api/licensesV3/getActive';
const getActive = async (): Promise<
SuccessResponse<LicenseV3EventQueueResModel> | ErrorResponse
> => {
const response = await axios.get('/licenses/active');
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default getActive;

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,21 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AuthDomain } from 'types/api/SAML/listDomain';
import { PayloadProps, Props } from 'types/api/SAML/postDomain';
const create = async (props: Props): Promise<SuccessResponseV2<AuthDomain>> => {
try {
const response = await axios.post<PayloadProps>(`/domains`, props);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default create;

View File

@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/SAML/deleteDomain';
const deleteDomain = async (props: Props): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<PayloadProps>(`/domains/${props.id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDomain;

View File

@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AuthDomain, PayloadProps } from 'types/api/SAML/listDomain';
const listAllDomain = async (): Promise<SuccessResponseV2<AuthDomain[]>> => {
try {
const response = await axios.get<PayloadProps>(`/domains`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listAllDomain;

View File

@ -0,0 +1,23 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AuthDomain } from 'types/api/SAML/listDomain';
import { PayloadProps, Props } from 'types/api/SAML/updateDomain';
const updateDomain = async (
props: Props,
): Promise<SuccessResponseV2<AuthDomain>> => {
try {
const response = await axios.put<PayloadProps>(`/domains/${props.id}`, props);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateDomain;

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

@ -0,0 +1,25 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
LicenseEventQueueResModel,
PayloadProps,
} from 'types/api/licensesV3/getActive';
const getActive = async (): Promise<
SuccessResponseV2<LicenseEventQueueResModel>
> => {
try {
const response = await axios.get<PayloadProps>('/licenses/active');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getActive;

View File

@ -0,0 +1,24 @@
import { ApiV3Instance as axios } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/licenses/apply';
const apply = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/licenses', {
key: props.key,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default apply;

View File

@ -0,0 +1,191 @@
import React from 'react';
type ErrorIconProps = React.SVGProps<SVGSVGElement>;
function ErrorIcon({ ...props }: ErrorIconProps): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
<path
fill="#C62828"
d="M1.281 5.78a.922.922 0 0 0-.92.921v4.265a.922.922 0 0 0 .92.92h.617V5.775l-.617.005ZM12.747 5.78c.508 0 .92.413.92.92v4.264a.923.923 0 0 1-.92.922h-.617V5.775l.617.004Z"
/>
<path
fill="#90A4AE"
d="M12.463 5.931 12.45 4.82a.867.867 0 0 0-.87-.861H7.34v-1.49a1.083 1.083 0 1 0-.68 0v1.496H2.42a.867.867 0 0 0-.864.86v7.976c.003.475.389.86.865.862h9.16a.868.868 0 0 0 .869-.862v-.82h.013v-6.05Z"
/>
<path
fill="#C62828"
d="M7 1.885a.444.444 0 1 1 0-.888.444.444 0 0 1 0 .888Z"
/>
<path
fill="url(#a)"
d="M4.795 10.379h4.412c.384 0 .696.312.696.697v.063a.697.697 0 0 1-.696.697H4.795a.697.697 0 0 1-.697-.697v-.063c0-.385.312-.697.697-.697Z"
/>
<path fill="url(#b)" d="M6.115 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#c)" d="M7.138 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#d)" d="M8.147 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#e)" d="M9.22 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#f)" d="M5.042 10.379H4.78v1.454h.262V10.38Z" />
<path
fill="#C62828"
d="M7 9.367h-.593a.111.111 0 0 1-.098-.162l.304-.6.288-.532a.11.11 0 0 1 .195 0l.29.556.301.576a.11.11 0 0 1-.098.162H7Z"
/>
<path
fill="url(#g)"
d="M4.627 8.587a1.278 1.278 0 1 0 0-2.556 1.278 1.278 0 0 0 0 2.556Z"
/>
<path
fill="url(#h)"
fillRule="evenodd"
d="M4.627 6.142a1.167 1.167 0 1 0 0 2.333 1.167 1.167 0 0 0 0-2.333ZM3.237 7.31a1.389 1.389 0 1 1 2.778 0 1.389 1.389 0 0 1-2.777 0Z"
clipRule="evenodd"
/>
<path
fill="url(#i)"
d="M9.333 6.028a1.278 1.278 0 1 0 0 2.556 1.278 1.278 0 0 0 0-2.556Z"
/>
<path
fill="url(#j)"
fillRule="evenodd"
d="M7.944 7.306a1.39 1.39 0 0 1 2.778 0 1.389 1.389 0 0 1-2.778 0Zm1.39-1.167a1.167 1.167 0 1 0 0 2.334 1.167 1.167 0 0 0 0-2.334Z"
clipRule="evenodd"
/>
<defs>
<linearGradient
id="a"
x1="7.001"
x2="7.001"
y1="11.836"
y2="10.379"
gradientUnits="userSpaceOnUse"
>
<stop offset=".12" stopColor="#E0E0E0" />
<stop offset=".52" stopColor="#fff" />
<stop offset="1" stopColor="#EAEAEA" />
</linearGradient>
<linearGradient
id="b"
x1="5.984"
x2="5.984"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="c"
x1="7.007"
x2="7.007"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="d"
x1="8.016"
x2="8.016"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="e"
x1="9.089"
x2="9.089"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="f"
x1="4.911"
x2="4.911"
y1="11.833"
y2="10.379"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="h"
x1="3.238"
x2="6.015"
y1="7.309"
y2="7.309"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="j"
x1="7.939"
x2="10.716"
y1="7.306"
y2="7.306"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<radialGradient
id="g"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(1.27771 0 0 1.2777 4.627 7.309)"
gradientUnits="userSpaceOnUse"
>
<stop offset=".48" stopColor="#fff" />
<stop offset=".77" stopColor="#FDFDFD" />
<stop offset=".88" stopColor="#F6F6F6" />
<stop offset=".96" stopColor="#EBEBEB" />
<stop offset="1" stopColor="#E0E0E0" />
</radialGradient>
<radialGradient
id="i"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(1.27771 0 0 1.2777 9.328 7.306)"
gradientUnits="userSpaceOnUse"
>
<stop offset=".48" stopColor="#fff" />
<stop offset=".77" stopColor="#FDFDFD" />
<stop offset=".88" stopColor="#F6F6F6" />
<stop offset=".96" stopColor="#EBEBEB" />
<stop offset="1" stopColor="#E0E0E0" />
</radialGradient>
</defs>
</svg>
);
}
export default ErrorIcon;

View File

@ -1,14 +1,14 @@
import { Button, Modal, Typography } from 'antd';
import updateCreditCardApi from 'api/billing/checkout';
import logEvent from 'api/common/logEvent';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import updateCreditCardApi from 'api/v1/checkout/create';
import { useNotifications } from 'hooks/useNotifications';
import { CreditCard, X } from 'lucide-react';
import { useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
export default function ChatSupportGateway(): JSX.Element {
const { notifications } = useNotifications();
@ -18,20 +18,21 @@ export default function ChatSupportGateway(): JSX.Element {
);
const handleBillingOnSuccess = (
data: ErrorResponse | SuccessResponse<CheckoutSuccessPayloadProps, unknown>,
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.payload?.redirectURL) {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.payload.redirectURL;
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (): void => {
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: SOMETHING_WENT_WRONG,
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};

View File

@ -0,0 +1,118 @@
.error-modal {
&__trigger {
width: fit-content;
display: flex;
align-items: center;
gap: 4px;
border-radius: 20px;
background: rgba(229, 72, 77, 0.2);
padding-left: 3px;
padding-right: 8px;
cursor: pointer;
span {
color: var(--bg-cherry-500);
font-size: 10px;
font-weight: 500;
line-height: 20px; /* 200% */
letter-spacing: 0.4px;
text-transform: uppercase;
}
}
&__wrap {
background: linear-gradient(
180deg,
rgba(11, 12, 14, 0.12) 0.07%,
rgba(39, 8, 14, 0.24) 50.04%,
rgba(106, 29, 44, 0.36) 75.02%,
rgba(197, 57, 85, 0.48) 87.51%,
rgba(242, 71, 105, 0.6) 100%
);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
.ant-modal {
bottom: 40px;
top: unset;
position: absolute;
width: 520px;
left: 0px;
right: 0px;
margin: auto;
}
}
&__body {
padding: 0;
background: var(--bg-ink-400);
overflow: hidden;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
&__header {
background: none !important;
.ant-modal-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.key-value-label {
padding: 0;
border: none;
border-radius: 4px;
overflow: hidden;
&__key,
&__value {
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.48px;
}
&__key {
text-transform: uppercase;
&,
&:hover {
color: var(--bg-vanilla-100);
}
}
&__value {
color: var(--bg-vanilla-400);
pointer-events: none;
}
}
.close-button {
padding: 3px 7px;
background: var(--bg-ink-400);
display: inline-flex;
align-items: center;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
box-shadow: none;
}
}
&__footer {
margin: 0 !important;
height: 6px;
background: var(--bg-sakura-500);
}
&__content {
padding: 0 !important;
border-radius: 4px;
overflow: hidden;
background: none !important;
}
}
.lightMode {
.error-modal {
&__body,
&__header .close-button {
background: var(--bg-vanilla-100);
}
&__header .close-button {
svg {
fill: var(--bg-vanilla-100);
}
}
}
}

View File

@ -0,0 +1,195 @@
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import ErrorModal from './ErrorModal';
// Mock the query client to return version data
const mockVersionData = {
payload: {
ee: 'Y',
version: '1.0.0',
},
};
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): { getQueryData: () => typeof mockVersionData } => ({
getQueryData: jest.fn(() => mockVersionData),
}),
}));
const mockError: APIError = new APIError({
httpStatusCode: 400,
error: {
// eslint-disable-next-line sonarjs/no-duplicate-string
message: 'Something went wrong while processing your request.',
// eslint-disable-next-line sonarjs/no-duplicate-string
code: 'An error occurred',
// eslint-disable-next-line sonarjs/no-duplicate-string
url: 'https://example.com/docs',
errors: [
{ message: 'First error detail' },
{ message: 'Second error detail' },
{ message: 'Third error detail' },
],
},
});
describe('ErrorModal Component', () => {
it('should render the modal when open is true', () => {
render(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// Check if the error message is displayed
expect(screen.getByText('An error occurred')).toBeInTheDocument();
expect(
screen.getByText('Something went wrong while processing your request.'),
).toBeInTheDocument();
});
it('should not render the modal when open is false', () => {
render(<ErrorModal error={mockError} open={false} onClose={jest.fn()} />);
// Check that the modal content is not in the document
expect(screen.queryByText('An error occurred')).not.toBeInTheDocument();
});
it('should call onClose when the close button is clicked', async () => {
const onCloseMock = jest.fn();
render(<ErrorModal error={mockError} open onClose={onCloseMock} />);
// Click the close button
const closeButton = screen.getByTestId('close-button');
act(() => {
fireEvent.click(closeButton);
});
// Check if onClose was called
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it('should display version data if available', async () => {
render(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// Check if the version data is displayed
expect(screen.getByText('ENTERPRISE')).toBeInTheDocument();
expect(screen.getByText('1.0.0')).toBeInTheDocument();
});
it('should render the messages count badge when there are multiple errors', () => {
render(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// Check if the messages count badge is displayed
expect(screen.getByText('MESSAGES')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
// Check if the individual error messages are displayed
expect(screen.getByText('First error detail')).toBeInTheDocument();
expect(screen.getByText('Second error detail')).toBeInTheDocument();
expect(screen.getByText('Third error detail')).toBeInTheDocument();
});
it('should render the open docs button when URL is provided', async () => {
render(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// Check if the open docs button is displayed
const openDocsButton = screen.getByTestId('error-docs-button');
expect(openDocsButton).toBeInTheDocument();
expect(openDocsButton).toHaveAttribute('href', 'https://example.com/docs');
expect(openDocsButton).toHaveAttribute('target', '_blank');
});
it('should not display scroll for more if there are less than 10 messages', () => {
render(<ErrorModal error={mockError} open onClose={jest.fn()} />);
expect(screen.queryByText('Scroll for more')).not.toBeInTheDocument();
});
it('should display scroll for more if there are more than 10 messages', async () => {
const longError = new APIError({
httpStatusCode: 400,
error: {
...mockError.error,
code: 'An error occurred',
message: 'Something went wrong while processing your request.',
url: 'https://example.com/docs',
errors: Array.from({ length: 15 }, (_, i) => ({
message: `Error detail ${i + 1}`,
})),
},
});
render(<ErrorModal error={longError} open onClose={jest.fn()} />);
// Check if the scroll hint is displayed
expect(screen.getByText('Scroll for more')).toBeInTheDocument();
});
});
it('should render the trigger component if provided', () => {
const mockTrigger = <button type="button">Open Error Modal</button>;
render(
<ErrorModal
error={mockError}
triggerComponent={mockTrigger}
onClose={jest.fn()}
/>,
);
// Check if the trigger component is rendered
expect(screen.getByText('Open Error Modal')).toBeInTheDocument();
});
it('should open the modal when the trigger component is clicked', async () => {
const mockTrigger = <button type="button">Open Error Modal</button>;
render(
<ErrorModal
error={mockError}
triggerComponent={mockTrigger}
onClose={jest.fn()}
/>,
);
// Click the trigger component
const triggerButton = screen.getByText('Open Error Modal');
act(() => {
fireEvent.click(triggerButton);
});
// Check if the modal is displayed
expect(screen.getByText('An error occurred')).toBeInTheDocument();
});
it('should render the default trigger tag if no trigger component is provided', () => {
render(<ErrorModal error={mockError} onClose={jest.fn()} />);
// Check if the default trigger tag is rendered
expect(screen.getByText('error')).toBeInTheDocument();
});
it('should close the modal when the onCancel event is triggered', async () => {
const onCloseMock = jest.fn();
render(<ErrorModal error={mockError} onClose={onCloseMock} />);
// Click the trigger component
const triggerButton = screen.getByText('error');
act(() => {
fireEvent.click(triggerButton);
});
await waitFor(() => {
expect(screen.getByText('An error occurred')).toBeInTheDocument();
});
// Trigger the onCancel event
act(() => {
fireEvent.click(screen.getByTestId('close-button'));
});
// Check if the modal is closed
expect(onCloseMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
// check if the modal is not visible
const modal = document.getElementsByClassName('ant-modal');
const style = window.getComputedStyle(modal[0]);
expect(style.display).toBe('none');
});
});

View File

@ -0,0 +1,102 @@
import './ErrorModal.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Modal, Tag } from 'antd';
import { CircleAlert, X } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { useAppContext } from 'providers/App/App';
import React from 'react';
import APIError from 'types/api/error';
import ErrorContent from './components/ErrorContent';
type Props = {
error: APIError;
triggerComponent?: React.ReactElement;
onClose?: () => void;
open?: boolean;
};
const classNames = {
body: 'error-modal__body',
mask: 'error-modal__mask',
header: 'error-modal__header',
footer: 'error-modal__footer',
content: 'error-modal__content',
};
function ErrorModal({
open,
error,
triggerComponent,
onClose,
}: Props): JSX.Element {
const [visible, setVisible] = React.useState(open);
const handleClose = (): void => {
setVisible(false);
onClose?.();
};
const { versionData } = useAppContext();
const versionDataPayload = versionData;
return (
<>
{!triggerComponent ? (
<Tag
className="error-modal__trigger"
icon={<CircleAlert size={14} color={Color.BG_CHERRY_500} />}
color="error"
onClick={(): void => setVisible(true)}
>
error
</Tag>
) : (
React.cloneElement(triggerComponent, {
onClick: () => setVisible(true),
})
)}
<Modal
open={visible}
footer={<div className="error-modal__footer" />}
title={
<>
{versionDataPayload ? (
<KeyValueLabel
badgeKey={versionDataPayload.ee === 'Y' ? 'ENTERPRISE' : 'COMMUNITY'}
badgeValue={versionDataPayload.version}
/>
) : (
<div className="error-modal__version-placeholder" />
)}
<Button
type="default"
className="close-button"
onClick={handleClose}
data-testid="close-button"
>
<X size={16} color={Color.BG_VANILLA_400} />
</Button>
</>
}
onCancel={handleClose}
closeIcon={false}
classNames={classNames}
wrapClassName="error-modal__wrap"
>
<ErrorContent error={error} />
</Modal>
</>
);
}
ErrorModal.defaultProps = {
onClose: undefined,
triggerComponent: null,
open: false,
};
export default ErrorModal;

View File

@ -0,0 +1,208 @@
.error-content {
display: flex;
flex-direction: column;
// === SECTION: Summary (Top)
&__summary-section {
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--bg-slate-400);
}
&__summary {
display: flex;
justify-content: space-between;
padding: 16px;
}
&__summary-left {
display: flex;
align-items: baseline;
gap: 8px;
}
&__summary-text {
display: flex;
flex-direction: column;
gap: 6px;
}
&__error-code {
color: var(--bg-vanilla-100);
margin: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
}
&__error-message {
margin: 0;
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
&__docs-button {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 12.5px;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: 0.12px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
box-shadow: none;
}
&__message-badge {
display: flex;
align-items: center;
gap: 12px;
padding: 0px 16px 16px;
.key-value-label {
width: fit-content;
border-color: var(--bg-slate-400);
border-radius: 20px;
overflow: hidden;
&__key {
padding-left: 8px;
padding-right: 8px;
}
&__value {
padding-right: 10px;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 18px; /* 150% */
letter-spacing: 0.48px;
pointer-events: none;
}
}
&-label {
display: flex;
align-items: center;
gap: 6px;
&-dot {
height: 6px;
width: 6px;
background: var(--bg-sakura-500);
border-radius: 50%;
}
&-text {
color: var(--bg-vanilla-100);
font-size: 10px;
font-weight: 500;
line-height: 18px; /* 180% */
letter-spacing: 0.5px;
}
}
&-line {
flex: 1;
height: 8px;
background-image: radial-gradient(circle, #444c63 1px, transparent 2px);
background-size: 8px 11px;
background-position: top left;
padding: 6px;
}
}
// === SECTION: Message List (Bottom)
&__message-list-container {
position: relative;
}
&__message-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 275px;
}
&__message-item {
position: relative;
margin-bottom: 4px;
color: var(--bg-vanilla-400);
font-family: Geist Mono;
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--bg-vanilla-400);
padding: 3px 12px;
padding-left: 26px;
}
&__message-item::before {
font-family: unset;
content: '';
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 2px;
height: 4px;
border-radius: 50px;
background: var(--bg-slate-400);
}
&__scroll-hint {
position: absolute;
bottom: 10px;
left: 0px;
right: 0px;
margin: auto;
width: fit-content;
display: inline-flex;
padding: 4px 12px 4px 10px;
justify-content: center;
align-items: center;
gap: 3px;
background: var(--bg-slate-200);
border-radius: 20px;
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
0px 66px 18px 0px rgba(0, 0, 0, 0.01), 0px 37px 22px 0px rgba(0, 0, 0, 0.03),
0px 17px 17px 0px rgba(0, 0, 0, 0.04), 0px 4px 9px 0px rgba(0, 0, 0, 0.04);
}
&__scroll-hint-text {
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
.lightMode {
.error-content {
&__error-code {
color: var(--bg-ink-100);
}
&__error-message {
color: var(--bg-ink-400);
}
&__message-item {
color: var(--bg-ink-400);
}
&__message-badge {
&-label-text {
color: var(--bg-ink-400);
}
.key-value-label__value {
color: var(--bg-ink-400);
}
}
&__docs-button {
background: var(--bg-vanilla-100);
color: var(--bg-ink-100);
}
}
}

View File

@ -0,0 +1,98 @@
import './ErrorContent.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import ErrorIcon from 'assets/Error';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { BookOpenText, ChevronsDown } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import APIError from 'types/api/error';
interface ErrorContentProps {
error: APIError;
}
function ErrorContent({ error }: ErrorContentProps): JSX.Element {
const {
url: errorUrl,
errors: errorMessages,
code: errorCode,
message: errorMessage,
} = error.error.error;
return (
<section className="error-content">
{/* Summary Header */}
<section className="error-content__summary-section">
<header className="error-content__summary">
<div className="error-content__summary-left">
<div className="error-content__icon-wrapper">
<ErrorIcon />
</div>
<div className="error-content__summary-text">
<h2 className="error-content__error-code">{errorCode}</h2>
<p className="error-content__error-message">{errorMessage}</p>
</div>
</div>
{errorUrl && (
<div className="error-content__summary-right">
<Button
type="default"
className="error-content__docs-button"
href={errorUrl}
target="_blank"
data-testid="error-docs-button"
>
<BookOpenText size={14} />
Open Docs
</Button>
</div>
)}
</header>
{errorMessages?.length > 0 && (
<div className="error-content__message-badge">
<KeyValueLabel
badgeKey={
<div className="error-content__message-badge-label">
<div className="error-content__message-badge-label-dot" />
<div className="error-content__message-badge-label-text">MESSAGES</div>
</div>
}
badgeValue={errorMessages.length.toString()}
/>
<div className="error-content__message-badge-line" />
</div>
)}
</section>
{/* Detailed Messages */}
<section className="error-content__messages-section">
<div className="error-content__message-list-container">
<OverlayScrollbar>
<ul className="error-content__message-list">
{errorMessages?.map((error) => (
<li className="error-content__message-item" key={error.message}>
{error.message}
</li>
))}
</ul>
</OverlayScrollbar>
{errorMessages?.length > 10 && (
<div className="error-content__scroll-hint">
<ChevronsDown
size={16}
color={Color.BG_VANILLA_100}
className="error-content__scroll-hint-icon"
/>
<span className="error-content__scroll-hint-text">Scroll for more</span>
</div>
)}
</div>
</section>
</section>
);
}
export default ErrorContent;

View File

@ -37,6 +37,7 @@ import {
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@ -67,6 +68,7 @@ function HostMetricsDetails({
AppState,
GlobalReducer
>((state) => state.globalTime);
const [searchParams, setSearchParams] = useSearchParams();
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
@ -86,7 +88,9 @@ function HostMetricsDetails({
selectedTime as Time,
);
const [selectedView, setSelectedView] = useState<VIEWS>(VIEWS.METRICS);
const [selectedView, setSelectedView] = useState<VIEWS>(
(searchParams.get('view') as VIEWS) || VIEWS.METRICS,
);
const isDarkMode = useIsDarkMode();
const initialFilters = useMemo(
@ -149,6 +153,9 @@ function HostMetricsDetails({
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
if (host?.hostName) {
setSearchParams({ hostName: host?.hostName, view: e.target.value });
}
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.HostEntity,
view: e.target.value,
@ -313,6 +320,7 @@ function HostMetricsDetails({
const handleClose = (): void => {
setSelectedInterval(selectedTime as Time);
setSearchParams({});
if (selectedTime !== 'custom') {
const { maxTime, minTime } = GetMinMax(selectedTime);

View File

@ -75,7 +75,6 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => (
<RawLogView
isReadOnly
isTextOverflowEllipsisDisabled
key={logToRender.id}
data={logToRender}

View File

@ -45,7 +45,7 @@ jest.mock(
},
);
describe('HostMetricsLogs', () => {
describe.skip('HostMetricsLogs', () => {
let capturedQueryRangePayloads: QueryRangePayload[] = [];
const itemHeight = 100;
beforeEach(() => {

View File

@ -80,6 +80,7 @@ function Metrics({
softMin: null,
minTimeScale: timeRange.startTime,
maxTimeScale: timeRange.endTime,
enableZoom: true,
}),
),
[queries, isDarkMode, dimensions, timeRange.startTime, timeRange.endTime],
@ -115,7 +116,7 @@ function Metrics({
<div className="metrics-header">
<div className="metrics-datetime-section">
<DateTimeSelectionV2
showAutoRefresh={false}
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}

View File

@ -1,10 +1,9 @@
import './LaunchChatSupport.styles.scss';
import { Button, Modal, Tooltip, Typography } from 'antd';
import updateCreditCardApi from 'api/billing/checkout';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import cx from 'classnames';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
@ -14,8 +13,9 @@ import { useAppContext } from 'providers/App/App';
import { useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useLocation } from 'react-router-dom';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
export interface LaunchChatSupportProps {
eventName: string;
@ -118,20 +118,21 @@ function LaunchChatSupport({
};
const handleBillingOnSuccess = (
data: ErrorResponse | SuccessResponse<CheckoutSuccessPayloadProps, unknown>,
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.payload?.redirectURL) {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.payload.redirectURL;
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (): void => {
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: SOMETHING_WENT_WRONG,
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};

View File

@ -3,6 +3,25 @@
background: var(--bg-ink-400);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.log-detail-drawer__title {
display: flex;
justify-content: space-between;
align-items: center;
.log-detail-drawer__title-left {
display: flex;
align-items: center;
gap: 8px;
}
.log-detail-drawer__title-right {
.ant-btn {
display: flex;
align-items: center;
}
}
}
.ant-drawer-header {
padding: 8px 16px;
border-bottom: none;

View File

@ -8,6 +8,8 @@ import { RadioChangeEvent } from 'antd/lib';
import cx from 'classnames';
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
@ -22,9 +24,12 @@ import dompurify from 'dompurify';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
BarChart2,
Braces,
Compass,
Copy,
Filter,
HardHat,
@ -33,9 +38,12 @@ import {
X,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { useSelector } from 'react-redux';
import { useCopyToClipboard, useLocation } from 'react-use';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
@ -77,6 +85,12 @@ function LogDetail({
});
const isDarkMode = useIsDarkMode();
const location = useLocation();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { notifications } = useNotifications();
@ -119,6 +133,21 @@ function LogDetail({
});
};
// Go to logs explorer page with the log data
const handleOpenInExplorer = (): void => {
urlQuery.set(QueryParams.activeLogId, `"${log?.id}"`);
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
};
// Only show when opened from infra monitoring page
const showOpenInExplorerBtn = useMemo(
() => location.pathname?.includes('/infrastructure-monitoring'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
if (!log) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
@ -131,10 +160,23 @@ function LogDetail({
width="60%"
maskStyle={{ background: 'none' }}
title={
<>
<div className="log-detail-drawer__title">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</>
</div>
{showOpenInExplorerBtn && (
<div className="log-detail-drawer__title-right">
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
}
placement="right"
// closable

View File

@ -30,6 +30,7 @@
.right-action {
display: flex;
align-items: center;
min-width: 48px;
.clear-all {
font-size: 12px;
@ -52,10 +53,14 @@
.checkbox-value-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
width: calc(100% - 24px);
cursor: pointer;
.value-string {
width: 100%;
}
&.filter-disabled {
cursor: not-allowed;
@ -74,9 +79,6 @@
}
}
.value-string {
}
.only-btn {
display: none;
}
@ -177,3 +179,17 @@
}
}
}
.label-false {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--bg-cherry-500);
}
.label-true {
width: 2px;
height: 11px;
border-radius: 2px;
background: var(--bg-forest-500);
}

View File

@ -504,6 +504,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
onChange(value, currentFilterState[value], true);
}}
>
<div className={`${filter.title} label-${value}`} />
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
@ -511,7 +512,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
className="value-string"
ellipsis={{ tooltip: { placement: 'right' } }}
>
{value}
{String(value)}
</Typography.Text>
)}
<Button type="text" className="only-btn">

View File

@ -0,0 +1,174 @@
.collapseContainer {
background-color: var(--bg-ink-500);
border-bottom: 1px solid var(--bg-slate-400);
.ant-collapse-header {
padding: 12px !important;
align-items: center !important;
}
.ant-collapse-expand-icon {
padding-right: 9px !important;
}
.ant-collapse-header-text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
}
.duration-inputs {
display: grid;
gap: 12px;
.min-max-input {
.ant-input-group-addon {
color: var(--bg-vanilla-400);
font-family: 'Space Mono', monospace;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: 0.48px;
padding: 0 6px;
}
.ant-input {
padding: 4px 6px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono', monospace;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: 0.48px;
}
}
}
}
.divider {
background-color: var(--bg-slate-400);
margin: 0;
border-color: var(--bg-slate-400);
}
.filter-header {
padding: 16px 8px 16px 12px;
.filter-title {
display: flex;
gap: 6px;
.ant-typography {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
}
.sync-icon {
background-color: var(--bg-ink-500);
border: 0;
box-shadow: none;
padding: 8px;
}
.arrow-icon {
background-color: var(--bg-ink-500);
border: 0;
box-shadow: none;
padding-top: 8px;
.anticon-vertical-align-top {
svg {
width: 16px;
height: 16px;
}
}
}
}
.section-body-header {
display: flex;
> button {
position: absolute;
right: 4px;
padding-top: 13px;
}
.ant-collapse {
width: 100%;
}
}
.section-card {
background-color: var(--bg-ink-500);
.ant-card-body {
padding: 0;
display: flex;
flex-direction: column;
max-height: 500px;
overflow-x: hidden;
overflow-y: auto;
}
width: 240px;
}
.lightMode {
.collapseContainer {
background-color: var(--bg-vanilla-100);
border-bottom: 1px solid var(--bg-vanilla-300);
.ant-collapse-header-text {
color: var(--bg-slate-100);
}
.duration-inputs {
.min-max-input {
.ant-input-group-addon {
color: var(--bg-slate-100);
}
.ant-input {
color: var(--bg-slate-100);
}
}
}
}
.divider {
background-color: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-200);
}
.filter-header {
.filter-title {
.ant-typography {
color: var(--bg-slate-100);
}
}
.arrow-icon {
background-color: var(--bg-vanilla-100);
}
.sync-icon {
background-color: var(--bg-vanilla-100);
}
}
.section-card {
background-color: var(--bg-vanilla-100);
box-shadow: none;
}
}

View File

@ -0,0 +1,281 @@
/* eslint-disable react-hooks/exhaustive-deps */
import './Duration.styles.scss';
import { Button, Collapse } from 'antd';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import { DurationSection } from 'pages/TracesExplorer/Filter/DurationSection';
import {
AllTraceFilterKeys,
AllTraceFilterKeyValue,
HandleRunProps,
unionTagFilterItems,
} from 'pages/TracesExplorer/Filter/filterUtils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
export type FilterType = Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>;
function Duration({
filter,
onFilterChange,
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] = useState<
Record<
AllTraceFilterKeys,
{ values: string[] | string; keys: BaseAutocompleteData }
>
>();
const [activeKeys, setActiveKeys] = useState<string[]>([
filter.defaultOpen ? 'durationNano' : '',
]);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {
const filters = compositeQuery?.builder.queryData?.[0].filters;
if (!filters) {
return {} as FilterType;
}
return (filters.items || [])
.filter((item) =>
Object.keys(AllTraceFilterKeyValue).includes(item.key?.key as string),
)
.filter(
(item) =>
(item.op === 'in' && item.key?.key !== 'durationNano') ||
(item.key?.key === 'durationNano' && ['>=', '<='].includes(item.op)),
)
.reduce((acc, item) => {
const keys = item.key as BaseAutocompleteData;
const attributeName = item.key?.key || '';
const values = item.value as string[];
if ((attributeName as AllTraceFilterKeys) === 'durationNano') {
if (item.op === '>=') {
acc.durationNanoMin = {
values: getMs(String(values)),
keys,
};
} else {
acc.durationNanoMax = {
values: getMs(String(values)),
keys,
};
}
return acc;
}
if (attributeName) {
if (acc[attributeName as AllTraceFilterKeys]) {
const existingValue = acc[attributeName as AllTraceFilterKeys];
acc[attributeName as AllTraceFilterKeys] = {
values: [...existingValue.values, ...values],
keys,
};
} else {
acc[attributeName as AllTraceFilterKeys] = { values, keys };
}
}
return acc;
}, {} as FilterType);
}, [compositeQuery]);
useEffect(() => {
if (!isEqual(syncSelectedFilters, selectedFilters)) {
setSelectedFilters(syncSelectedFilters);
}
}, [syncSelectedFilters]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const preparePostData = (): TagFilterItem[] => {
if (!selectedFilters) {
return [];
}
const items = Object.keys(selectedFilters)?.flatMap((attribute) => {
const { keys, values } = selectedFilters[attribute as AllTraceFilterKeys];
if (
['durationNanoMax', 'durationNanoMin', 'durationNano'].includes(
attribute as AllTraceFilterKeys,
)
) {
if (!values || !values.length) {
return [];
}
let minValue = '';
let maxValue = '';
const durationItems: TagFilterItem[] = [];
if (isArray(values)) {
minValue = values?.[0];
maxValue = values?.[1];
const minItems: TagFilterItem = {
id: uuid().slice(0, 8),
op: '>=',
key: keys,
value: Number(minValue) * 1000000,
};
const maxItems: TagFilterItem = {
id: uuid().slice(0, 8),
op: '<=',
key: keys,
value: Number(maxValue) * 1000000,
};
return maxValue ? [minItems, maxItems] : [minItems];
}
if (attribute === 'durationNanoMin') {
durationItems.push({
id: uuid().slice(0, 8),
op: '>=',
key: keys,
value: Number(values) * 1000000,
});
} else {
durationItems.push({
id: uuid().slice(0, 8),
op: '<=',
key: keys,
value: Number(values) * 1000000,
});
}
return durationItems;
}
return {
id: uuid().slice(0, 8),
key: keys,
op: 'in',
value: values,
};
});
return items as TagFilterItem[];
};
const removeFilterItemIds = (query: Query): Query => {
const clonedQuery = cloneDeep(query);
clonedQuery.builder.queryData = clonedQuery.builder.queryData.map((data) => ({
...data,
filters: {
...data.filters,
items: data.filters?.items?.map((item) => ({
...item,
id: '',
})),
},
}));
return clonedQuery;
};
const handleRun = useCallback(
(props?: HandleRunProps): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
filters: {
...item.filters,
items: props?.resetAll
? []
: (unionTagFilterItems(item.filters?.items, preparePostData())
.map((item) =>
item.key?.key === props?.clearByType ? undefined : item,
)
.filter((i) => i) as TagFilterItem[]),
},
})),
},
};
const currentQueryWithoutIds = removeFilterItemIds(currentQuery);
const preparedQueryWithoutIds = removeFilterItemIds(preparedQuery);
if (
isEqual(currentQueryWithoutIds, preparedQueryWithoutIds) &&
!props?.resetAll
) {
return;
}
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(preparedQuery);
} else {
redirectWithQueryBuilderData(preparedQuery);
}
},
[currentQuery, redirectWithQueryBuilderData, selectedFilters],
);
useEffect(() => {
handleRun();
}, [selectedFilters]);
const onClearHandler = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) {
handleRun({ clearByType: 'durationNano' });
}
};
return (
<div className="section-body-header" data-testid="collapse-duration">
<Collapse
bordered={false}
className="collapseContainer"
activeKey={activeKeys}
onChange={(keys): void => setActiveKeys(keys as string[])}
items={[
{
key: 'durationNano',
children: (
<DurationSection
setSelectedFilters={setSelectedFilters}
selectedFilters={selectedFilters}
/>
),
label: 'Duration',
},
]}
/>
{activeKeys.includes('durationNano') && (
<Button
type="link"
onClick={onClearHandler}
data-testid="collapse-duration-clearBtn"
>
Clear All
</Button>
)}
</div>
);
}
Duration.defaultProps = {
onFilterChange: (): void => {},
};
export default Duration;

View File

@ -5,19 +5,24 @@ import {
SyncOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
import { Skeleton, Tooltip, Typography } from 'antd';
import { Skeleton, Switch, Tooltip, Typography } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isFunction, isNull } from 'lodash-es';
import { Settings2 as SettingsIcon } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
import useFilterConfig from './hooks/useFilterConfig';
import AnnouncementTooltip from './QuickFiltersSettings/AnnouncementTooltip';
@ -32,8 +37,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
source,
onFilterChange,
signal,
showFilterCollapse = true,
showQueryName = true,
} = props;
const { user } = useAppContext();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const isAdmin = user.role === USER_ROLES.ADMIN;
const [params, setParams] = useApiMonitoringParams();
const showIP = params.showIP ?? true;
const {
filterConfig,
@ -95,13 +106,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
};
const lastQueryName =
showQueryName &&
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
return (
<div className="quick-filters-container">
<div className="quick-filters">
{source !== QuickFiltersSource.INFRA_MONITORING &&
source !== QuickFiltersSource.API_MONITORING && (
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
<section className="left-actions">
<FilterOutlined />
@ -109,12 +120,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
{lastQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{lastQueryName && (
<Tooltip
title={`Filter currently in sync with query ${lastQueryName}`}
>
<Typography.Text className="sync-tag">
{lastQueryName}
</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
)}
</section>
@ -125,6 +132,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<SyncOutlined className="sync-icon" onClick={handleReset} />
</div>
</Tooltip>
{showFilterCollapse && (
<Tooltip title="Collapse Filters">
<div className="right-action-icon-container">
<VerticalAlignTopOutlined
@ -133,7 +141,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
/>
</div>
</Tooltip>
{isDynamicFilters && (
)}
{isDynamicFilters && isAdmin && (
<Tooltip title="Settings">
<div
className={classNames('right-action-icon-container', {
@ -179,6 +188,23 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</div>
) : (
<OverlayScrollbar>
<>
{source === QuickFiltersSource.API_MONITORING && (
<div className="api-quick-filters-header">
<Typography.Text>Show IP addresses</Typography.Text>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP ?? true}
onClick={(): void => {
logEvent('API Monitoring: Show IP addresses clicked', {
showIP: !(showIP ?? true),
});
setParams({ showIP });
}}
/>
</div>
)}
<section className="filters">
{filterConfig.map((filter) => {
switch (filter.type) {
@ -190,6 +216,8 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
onFilterChange={onFilterChange}
/>
);
case FiltersType.DURATION:
return <Duration filter={filter} onFilterChange={onFilterChange} />;
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
@ -204,6 +232,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
}
})}
</section>
</>
</OverlayScrollbar>
)}
</div>
@ -235,4 +264,6 @@ QuickFilters.defaultProps = {
onFilterChange: null,
signal: '',
config: [],
showFilterCollapse: true,
showQueryName: true,
};

View File

@ -3,10 +3,12 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useMemo } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
function OtherFiltersSkeleton(): JSX.Element {
return (
@ -34,6 +36,11 @@ function OtherFilters({
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
}): JSX.Element {
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const {
data: suggestionsData,
isFetching: isFetchingSuggestions,
@ -45,18 +52,39 @@ function OtherFilters({
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal,
enabled: !!signal && isLogDataSource,
},
);
const otherFilters = useMemo(
() =>
suggestionsData?.payload?.attributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
),
[suggestionsData, addedFilters],
const {
data: aggregateKeysData,
isFetching: isFetchingAggregateKeys,
} = useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [suggestionsData, aggregateKeysData, addedFilters, isLogDataSource]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
@ -71,7 +99,8 @@ function OtherFilters({
};
const renderFilters = (): React.ReactNode => {
if (isFetchingSuggestions) return <OtherFiltersSkeleton />;
const isLoading = isFetchingSuggestions || isFetchingAggregateKeys;
if (isLoading) return <OtherFiltersSkeleton />;
if (!otherFilters?.length)
return <div className="no-values-found">No values found</div>;

View File

@ -7,6 +7,7 @@
background: var(--bg-slate-500);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--bg-vanilla-100);
&.qf-logs-explorer {
height: calc(100vh - 45px);
@ -16,6 +17,14 @@
height: 100vh;
}
&.qf-api-monitoring {
height: calc(100vh - 45px);
}
&.qf-traces-explorer {
height: calc(100vh - 45px);
}
&.hidden {
width: 0;
}
@ -172,6 +181,7 @@
.lightMode {
.quick-filters-settings {
background: var(--bg-vanilla-100);
color: var(--bg-slate-500);
.search {
.ant-input {
background-color: var(--bg-vanilla-100);

View File

@ -1,3 +1,4 @@
import logEvent from 'api/common/logEvent';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import { SignalType } from 'components/QuickFilters/types';
@ -46,6 +47,9 @@ const useQuickFilterSettings = ({
onSuccess: () => {
setIsSettingsOpen(false);
setIsStale(true);
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',

View File

@ -33,7 +33,7 @@ const useFilterConfig = ({
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
customFilters,
]);
const { isLoading: isCustomFiltersLoading } = useQuery<
const { isFetching: isCustomFiltersLoading } = useQuery<
SuccessResponse<PayloadProps> | ErrorResponse,
Error
>(
@ -49,10 +49,10 @@ const useFilterConfig = ({
enabled: !!signal && isStale,
},
);
const filterConfig = useMemo(() => getFilterConfig(customFilters, config), [
config,
customFilters,
]);
const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config),
[config, customFilters, signal],
);
return {
filterConfig,

View File

@ -1,6 +1,7 @@
import '@testing-library/jest-dom';
import {
act,
cleanup,
fireEvent,
render,
@ -8,6 +9,7 @@ import {
waitFor,
} from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
@ -17,6 +19,7 @@ import {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { USER_ROLES } from 'types/roles';
import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
@ -26,6 +29,21 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
// eslint-disable-next-line sonarjs/no-duplicate-string
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
}));
const userRole = USER_ROLES.ADMIN;
// mock useAppContext
jest.mock('providers/App/App', () => ({
useAppContext: jest.fn(() => ({ user: { role: userRole } })),
}));
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
@ -163,7 +181,9 @@ describe('Quick Filters with custom filters', () => {
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
await screen.findByText(FILTER_SERVICE_NAME);
await screen.findByText('otel-demo');
const allByText = await screen.findAllByText('otel-demo');
// since 2 filter collapse are open, there are 2 filter items visible
expect(allByText).toHaveLength(2);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
@ -285,4 +305,59 @@ describe('Quick Filters with custom filters', () => {
);
expect(requestBody.signal).toBe(SIGNAL);
});
// render duration filter
it('should render duration slider for duration_nono filter', async () => {
// Set up fake timers **before rendering**
jest.useFakeTimers();
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
expect(screen.getByText('Duration')).toBeInTheDocument();
// click to open the duration filter
fireEvent.click(screen.getByText('Duration'));
const minDuration = getByTestId('min-input') as HTMLInputElement;
const maxDuration = getByTestId('max-input') as HTMLInputElement;
expect(minDuration).toHaveValue(null);
expect(minDuration).toHaveProperty('placeholder', '0');
expect(maxDuration).toHaveValue(null);
expect(maxDuration).toHaveProperty('placeholder', '100000000');
await act(async () => {
// set values
fireEvent.change(minDuration, { target: { value: '10000' } });
fireEvent.change(maxDuration, { target: { value: '20000' } });
jest.advanceTimersByTime(2000);
});
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: {
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'durationNano' }),
op: '>=',
value: 10000000000,
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'durationNano' }),
op: '<=',
value: 20000000000,
}),
]),
}),
}),
]),
},
}),
);
});
jest.useRealTimers(); // Clean up
});
});

View File

@ -5,6 +5,7 @@ import { DataSource } from 'types/common/queryBuilder';
export enum FiltersType {
SLIDER = 'SLIDER',
CHECKBOX = 'CHECKBOX',
DURATION = 'DURATION', // ALIAS FOR DURATION_NANO
}
export enum MinMax {
@ -42,6 +43,8 @@ export interface IQuickFiltersProps {
onFilterChange?: (query: Query) => void;
signal?: SignalType;
className?: string;
showFilterCollapse?: boolean;
showQueryName?: boolean;
}
export enum QuickFiltersSource {

View File

@ -1,30 +1,53 @@
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { FiltersType, IQuickFiltersConfig } from './types';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
const getFilterName = (str: string): string =>
const FILTER_TITLE_MAP: Record<string, string> = {
duration_nano: 'Duration',
hasError: 'Has Error (Status)',
};
const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
}
// replace . and _ with space
// capitalize the first letter of each word
str
return str
.replace(/\./g, ' ')
.replace(/_/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: FilterType[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length) {
if (!customFilters?.length || !signal) {
return config || [];
}
return customFilters.map(
(att, index) =>
({
type: FiltersType.CHECKBOX,
type: getFilterType(att),
title: getFilterName(att.key),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.key,
key: att.key,
@ -33,7 +56,7 @@ export const getFilterConfig = (
isColumn: att.isColumn,
isJSON: att.isJSON,
},
defaultOpen: index === 0,
defaultOpen: index < 2,
} as IQuickFiltersConfig),
);
};

View File

@ -15,7 +15,7 @@ import {
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
@ -30,6 +30,14 @@ jest.mock('hooks/useNotifications', () => ({
},
})),
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
@ -119,7 +127,7 @@ describe('Create Alert Channel', () => {
fireEvent.click(saveButton);
await waitFor(() => expect(errorNotification).toHaveBeenCalled());
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
});
it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => {
server.use(
@ -151,9 +159,11 @@ describe('Create Alert Channel', () => {
name: 'button_test_channel',
});
act(() => {
fireEvent.click(testButton);
});
await waitFor(() => expect(errorNotification).toHaveBeenCalled());
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {

View File

@ -1,23 +1,16 @@
import './Explorer.styles.scss';
import { FilterOutlined } from '@ant-design/icons';
import * as Sentry from '@sentry/react';
import { Switch, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useEffect } from 'react';
import { useApiMonitoringParams } from '../queryParams';
import { ApiMonitoringQuickFiltersConfig } from '../utils';
import DomainList from './Domains/DomainList';
function Explorer(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const showIP = params.showIP ?? true;
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
@ -26,29 +19,12 @@ function Explorer(): JSX.Element {
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<div className="api-quick-filters-header">
<FilterOutlined />
<Typography.Text>Filters</Typography.Text>
</div>
<div className="api-quick-filters-header">
<Typography.Text>Show IP addresses</Typography.Text>
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP ?? true}
onClick={(): void => {
logEvent('API Monitoring: Show IP addresses clicked', {
showIP: !(showIP ?? true),
});
setParams({ showIP });
}}
/>
</div>
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
config={ApiMonitoringQuickFiltersConfig}
signal={SignalType.API_MONITORING}
showFilterCollapse={false}
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
/>
</section>

View File

@ -5,16 +5,15 @@ import './AppLayout.styles.scss';
import * as Sentry from '@sentry/react';
import { Flex } from 'antd';
import manageCreditCardApi from 'api/billing/manage';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import manageCreditCardApi from 'api/v1/portal/create';
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
import getUserVersion from 'api/v1/version/getVersion';
import cx from 'classnames';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { Events } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
@ -51,8 +50,9 @@ import {
UPDATE_LATEST_VERSION,
UPDATE_LATEST_VERSION_ERROR,
} from 'types/actions/app';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import APIError from 'types/api/error';
import {
LicenseEvent,
LicensePlatform,
@ -75,8 +75,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isLoggedIn,
user,
trialInfo,
activeLicenseV3,
isFetchingActiveLicenseV3,
activeLicense,
isFetchingActiveLicense,
featureFlags,
isFetchingFeatureFlags,
featureFlagsFetchError,
@ -93,20 +93,21 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const [slowApiWarningShown, setSlowApiWarningShown] = useState(false);
const handleBillingOnSuccess = (
data: ErrorResponse | SuccessResponse<CheckoutSuccessPayloadProps, unknown>,
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.payload?.redirectURL) {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.payload.redirectURL;
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();
}
};
const handleBillingOnError = (): void => {
const handleBillingOnError = (error: APIError): void => {
notifications.error({
message: SOMETHING_WENT_WRONG,
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
};
@ -260,8 +261,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
useEffect(() => {
if (
!isFetchingActiveLicenseV3 &&
activeLicenseV3 &&
!isFetchingActiveLicense &&
activeLicense &&
trialInfo?.onTrial &&
!trialInfo?.trialConvertedToSubscription &&
!trialInfo?.workSpaceBlock &&
@ -269,16 +270,16 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
) {
setShowTrialExpiryBanner(true);
}
}, [isFetchingActiveLicenseV3, activeLicenseV3, trialInfo]);
}, [isFetchingActiveLicense, activeLicense, trialInfo]);
useEffect(() => {
if (!isFetchingActiveLicenseV3 && activeLicenseV3) {
const isTerminated = activeLicenseV3.state === LicenseState.TERMINATED;
const isExpired = activeLicenseV3.state === LicenseState.EXPIRED;
const isCancelled = activeLicenseV3.state === LicenseState.CANCELLED;
const isDefaulted = activeLicenseV3.state === LicenseState.DEFAULTED;
if (!isFetchingActiveLicense && activeLicense) {
const isTerminated = activeLicense.state === LicenseState.TERMINATED;
const isExpired = activeLicense.state === LicenseState.EXPIRED;
const isCancelled = activeLicense.state === LicenseState.CANCELLED;
const isDefaulted = activeLicense.state === LicenseState.DEFAULTED;
const isEvaluationExpired =
activeLicenseV3.state === LicenseState.EVALUATION_EXPIRED;
activeLicense.state === LicenseState.EVALUATION_EXPIRED;
const isWorkspaceAccessRestricted =
isTerminated ||
@ -287,7 +288,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isDefaulted ||
isEvaluationExpired;
const { platform } = activeLicenseV3;
const { platform } = activeLicense;
if (
isWorkspaceAccessRestricted &&
@ -296,17 +297,17 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
setShowWorkspaceRestricted(true);
}
}
}, [isFetchingActiveLicenseV3, activeLicenseV3]);
}, [isFetchingActiveLicense, activeLicense]);
useEffect(() => {
if (
!isFetchingActiveLicenseV3 &&
!isNull(activeLicenseV3) &&
activeLicenseV3?.event_queue?.event === LicenseEvent.DEFAULT
!isFetchingActiveLicense &&
!isNull(activeLicense) &&
activeLicense?.event_queue?.event === LicenseEvent.DEFAULT
) {
setShowPaymentFailedWarning(true);
}
}, [activeLicenseV3, isFetchingActiveLicenseV3]);
}, [activeLicense, isFetchingActiveLicense]);
useEffect(() => {
// after logging out hide the trial expiry banner
@ -392,7 +393,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
if (
!isFetchingFeatureFlags &&
(featureFlags || featureFlagsFetchError) &&
activeLicenseV3 &&
activeLicense &&
trialInfo
) {
let isChatSupportEnabled = false;
@ -421,7 +422,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isCloudUserVal,
isFetchingFeatureFlags,
isLoggedIn,
activeLicenseV3,
activeLicense,
trialInfo,
]);
@ -523,14 +524,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const renderWorkspaceRestrictedBanner = (): JSX.Element => (
<div className="workspace-restricted-banner">
{activeLicenseV3?.state === LicenseState.TERMINATED && (
{activeLicense?.state === LicenseState.TERMINATED && (
<>
Your SigNoz license is terminated, enterprise features have been disabled.
Please contact support at{' '}
<a href="mailto:support@signoz.io">support@signoz.io</a> for new license
</>
)}
{activeLicenseV3?.state === LicenseState.EXPIRED && (
{activeLicense?.state === LicenseState.EXPIRED && (
<>
Your SigNoz license has expired. Please contact support at{' '}
<a href="mailto:support@signoz.io">support@signoz.io</a> for renewal to
@ -544,7 +545,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</a>
</>
)}
{activeLicenseV3?.state === LicenseState.CANCELLED && (
{activeLicense?.state === LicenseState.CANCELLED && (
<>
Your SigNoz license is cancelled. Please contact support at{' '}
<a href="mailto:support@signoz.io">support@signoz.io</a> for reactivation
@ -559,7 +560,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</>
)}
{activeLicenseV3?.state === LicenseState.DEFAULTED && (
{activeLicense?.state === LicenseState.DEFAULTED && (
<>
Your SigNoz license is defaulted. Please clear the bill to continue using
the enterprise features. Contact support at{' '}
@ -575,7 +576,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</>
)}
{activeLicenseV3?.state === LicenseState.EVALUATION_EXPIRED && (
{activeLicense?.state === LicenseState.EVALUATION_EXPIRED && (
<>
Your SigNoz trial has ended. Please contact support at{' '}
<a href="mailto:support@signoz.io">support@signoz.io</a> for next steps to
@ -624,7 +625,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
Your bill payment has failed. Your workspace will get suspended on{' '}
<span>
{getFormattedDateWithMinutes(
dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() || Date.now(),
dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(),
)}
.
</span>

View File

@ -16,10 +16,10 @@ import {
Typography,
} from 'antd';
import { ColumnsType } from 'antd/es/table';
import updateCreditCardApi from 'api/billing/checkout';
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
import manageCreditCardApi from 'api/billing/manage';
import logEvent from 'api/common/logEvent';
import updateCreditCardApi from 'api/v1/checkout/create';
import manageCreditCardApi from 'api/v1/portal/create';
import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@ -31,9 +31,8 @@ import { useAppContext } from 'providers/App/App';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { SuccessResponseV2 } from 'types/api';
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { License } from 'types/api/licenses/def';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
@ -126,7 +125,6 @@ export default function BillingContainer(): JSX.Element {
const daysRemainingStr = t('days_remaining');
const [headerText, setHeaderText] = useState('');
const [billAmount, setBillAmount] = useState(0);
const [activeLicense, setActiveLicense] = useState<License | null>(null);
const [daysRemaining, setDaysRemaining] = useState(0);
const [isFreeTrial, setIsFreeTrial] = useState(false);
const [data, setData] = useState<any[]>([]);
@ -137,11 +135,10 @@ export default function BillingContainer(): JSX.Element {
const {
user,
org,
licenses,
trialInfo,
isFetchingActiveLicenseV3,
activeLicenseV3,
activeLicenseV3FetchError,
isFetchingActiveLicense,
activeLicense,
activeLicenseFetchError,
} = useAppContext();
const { notifications } = useNotifications();
@ -216,14 +213,9 @@ export default function BillingContainer(): JSX.Element {
});
useEffect(() => {
const activeValidLicense =
licenses?.licenses?.find((license) => license.isCurrent === true) || null;
setActiveLicense(activeValidLicense);
if (
!isFetchingActiveLicenseV3 &&
!activeLicenseV3FetchError &&
!isFetchingActiveLicense &&
!activeLicenseFetchError &&
trialInfo?.onTrial
) {
const remainingDays = getRemainingDays(trialInfo?.trialEnd);
@ -238,12 +230,11 @@ export default function BillingContainer(): JSX.Element {
);
}
}, [
licenses?.licenses,
activeLicenseV3,
activeLicense,
trialInfo?.onTrial,
trialInfo?.trialEnd,
isFetchingActiveLicenseV3,
activeLicenseV3FetchError,
isFetchingActiveLicense,
activeLicenseFetchError,
]);
const columns: ColumnsType<DataType> = [
@ -288,11 +279,11 @@ export default function BillingContainer(): JSX.Element {
);
const handleBillingOnSuccess = (
data: ErrorResponse | SuccessResponse<CheckoutSuccessPayloadProps, unknown>,
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
): void => {
if (data?.payload?.redirectURL) {
if (data?.data?.redirectURL) {
const newTab = document.createElement('a');
newTab.href = data.payload.redirectURL;
newTab.href = data.data.redirectURL;
newTab.target = '_blank';
newTab.rel = 'noopener noreferrer';
newTab.click();

View File

@ -16,6 +16,7 @@ import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import APIError from 'types/api/error';
@ -42,6 +43,7 @@ function CreateAlertChannels({
}: CreateAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const { showErrorModal } = useErrorModal();
const [formInstance] = Form.useForm();
@ -145,15 +147,12 @@ function CreateAlertChannels({
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
notifications.error({
message: (error as APIError).error.error.code,
description: (error as APIError).error.error.message,
});
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareSlackRequest, t, notifications]);
}, [prepareSlackRequest, notifications, t, showErrorModal]);
const prepareWebhookRequest = useCallback(() => {
// initial api request without auth params
@ -202,15 +201,12 @@ function CreateAlertChannels({
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareWebhookRequest, t, notifications]);
}, [prepareWebhookRequest, notifications, t, showErrorModal]);
const preparePagerRequest = useCallback(() => {
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
@ -254,15 +250,12 @@ function CreateAlertChannels({
}
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [t, notifications, preparePagerRequest]);
}, [preparePagerRequest, t, notifications, showErrorModal]);
const prepareOpsgenieRequest = useCallback(
() => ({
@ -287,15 +280,12 @@ function CreateAlertChannels({
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareOpsgenieRequest, t, notifications]);
}, [prepareOpsgenieRequest, notifications, t, showErrorModal]);
const prepareEmailRequest = useCallback(
() => ({
@ -320,15 +310,12 @@ function CreateAlertChannels({
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, t, notifications]);
}, [prepareEmailRequest, notifications, t, showErrorModal]);
const prepareMsTeamsRequest = useCallback(
() => ({
@ -353,15 +340,12 @@ function CreateAlertChannels({
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareMsTeamsRequest, t, notifications]);
}, [prepareMsTeamsRequest, notifications, t, showErrorModal]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
@ -459,10 +443,8 @@ function CreateAlertChannels({
status: 'Test success',
});
} catch (error) {
notifications.error({
message: (error as APIError).error.error.code,
description: (error as APIError).error.error.message,
});
showErrorModal(error as APIError);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,

View File

@ -14,6 +14,8 @@ function ExplorerOptionWrapper({
isLoading,
onExport,
sourcepage,
isOneChartPerQuery,
splitedQueries,
}: ExplorerOptionsWrapperProps): JSX.Element {
const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false);
@ -32,6 +34,8 @@ function ExplorerOptionWrapper({
sourcepage={sourcepage}
isExplorerOptionHidden={isExplorerOptionHidden}
setIsExplorerOptionHidden={setIsExplorerOptionHidden}
isOneChartPerQuery={isOneChartPerQuery}
splitedQueries={splitedQueries}
/>
);
}

View File

@ -8,6 +8,21 @@
display: flex;
gap: 16px;
background-color: transparent;
.multi-alert-button,
.multi-dashboard-button {
min-width: 130px;
.ant-select-selector {
.ant-select-selection-placeholder {
margin-left: 0 !important;
}
.ant-select-arrow {
display: none !important;
}
}
}
}
.hide-update {

View File

@ -90,6 +90,8 @@ function ExplorerOptions({
sourcepage,
isExplorerOptionHidden = false,
setIsExplorerOptionHidden,
isOneChartPerQuery = false,
splitedQueries = [],
}: ExplorerOptionsProps): JSX.Element {
const [isExport, setIsExport] = useState<boolean>(false);
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
@ -99,6 +101,8 @@ function ExplorerOptions({
const history = useHistory();
const ref = useRef<RefSelectProps>(null);
const isDarkMode = useIsDarkMode();
const [queryToExport, setQueryToExport] = useState<Query | null>(null);
const isLogsExplorer = sourcepage === DataSource.LOGS;
const isMetricsExplorer = sourcepage === DataSource.METRICS;
@ -149,22 +153,28 @@ function ExplorerOptions({
const { user } = useAppContext();
const handleConditionalQueryModification = useCallback((): string => {
const handleConditionalQueryModification = useCallback(
(defaultQuery: Query | null): string => {
const queryToUse = defaultQuery || query;
if (
query?.builder?.queryData?.[0]?.aggregateOperator !== StringOperators.NOOP
queryToUse?.builder?.queryData?.[0]?.aggregateOperator !==
StringOperators.NOOP
) {
return JSON.stringify(query);
return JSON.stringify(queryToUse);
}
// Modify aggregateOperator to count, as noop is not supported in alerts
const modifiedQuery = cloneDeep(query);
const modifiedQuery = cloneDeep(queryToUse);
modifiedQuery.builder.queryData[0].aggregateOperator = StringOperators.COUNT;
return JSON.stringify(modifiedQuery);
}, [query]);
},
[query],
);
const onCreateAlertsHandler = useCallback(() => {
const onCreateAlertsHandler = useCallback(
(defaultQuery: Query | null) => {
if (sourcepage === DataSource.TRACES) {
logEvent('Traces Explorer: Create alert', {
panelType,
@ -179,21 +189,26 @@ function ExplorerOptions({
});
}
const stringifiedQuery = handleConditionalQueryModification();
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleConditionalQueryModification, history]);
[handleConditionalQueryModification, history],
);
const onCancel = (value: boolean) => (): void => {
onModalToggle(value);
if (isOneChartPerQuery) {
setQueryToExport(null);
}
};
const onAddToDashboard = (): void => {
const onAddToDashboard = useCallback((): void => {
if (sourcepage === DataSource.TRACES) {
logEvent('Traces Explorer: Add to dashboard clicked', {
panelType,
@ -208,7 +223,7 @@ function ExplorerOptions({
});
}
setIsExport(true);
};
}, [isLogsExplorer, isMetricsExplorer, panelType, setIsExport, sourcepage]);
const {
data: viewsData,
@ -616,6 +631,120 @@ function ExplorerOptions({
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
}, [isLogsExplorer, isMetricsExplorer]);
const getQueryName = (query: Query): string => {
if (query.builder.queryFormulas.length > 0) {
return `Formula ${query.builder.queryFormulas[0].queryName}`;
}
return `Query ${query.builder.queryData[0].queryName}`;
};
const alertButton = useMemo(() => {
if (isOneChartPerQuery) {
const selectLabel = (
<Button
disabled={disabled}
shape="round"
icon={<ConciergeBell size={16} />}
>
Create an Alert
</Button>
);
return (
<Select
disabled={disabled}
className="multi-alert-button"
placeholder={selectLabel}
value={selectLabel}
suffixIcon={null}
onSelect={(e): void => {
const selectedQuery = splitedQueries.find(
(query) => query.id === ((e as unknown) as string),
);
if (selectedQuery) {
onCreateAlertsHandler(selectedQuery);
}
}}
>
{splitedQueries.map((splittedQuery) => (
<Select.Option key={splittedQuery.id} value={splittedQuery.id}>
{getQueryName(splittedQuery)}
</Select.Option>
))}
</Select>
);
}
return (
<Button
disabled={disabled}
shape="round"
onClick={(): void => onCreateAlertsHandler(query)}
icon={<ConciergeBell size={16} />}
>
Create an Alert
</Button>
);
}, [
disabled,
isOneChartPerQuery,
onCreateAlertsHandler,
query,
splitedQueries,
]);
const dashboardButton = useMemo(() => {
if (isOneChartPerQuery) {
const selectLabel = (
<Button
type="primary"
disabled={disabled}
shape="round"
onClick={onAddToDashboard}
icon={<Plus size={16} />}
>
Add to Dashboard
</Button>
);
return (
<Select
disabled={disabled}
className="multi-dashboard-button"
placeholder={selectLabel}
value={selectLabel}
suffixIcon={null}
onSelect={(e): void => {
const selectedQuery = splitedQueries.find(
(query) => query.id === ((e as unknown) as string),
);
if (selectedQuery) {
setQueryToExport(() => {
onAddToDashboard();
return selectedQuery;
});
}
}}
>
{/* eslint-disable-next-line sonarjs/no-identical-functions */}
{splitedQueries.map((splittedQuery) => (
<Select.Option key={splittedQuery.id} value={splittedQuery.id}>
{getQueryName(splittedQuery)}
</Select.Option>
))}
</Select>
);
}
return (
<Button
type="primary"
disabled={disabled}
shape="round"
onClick={onAddToDashboard}
icon={<Plus size={16} />}
>
Add to Dashboard
</Button>
);
}, [disabled, isOneChartPerQuery, onAddToDashboard, splitedQueries]);
return (
<div className="explorer-options-container">
{
@ -719,24 +848,8 @@ function ExplorerOptions({
<hr className={isEditDeleteSupported ? '' : 'hidden'} />
<div className={cx('actions', isEditDeleteSupported ? '' : 'hidden')}>
<Button
disabled={disabled}
shape="round"
onClick={onCreateAlertsHandler}
icon={<ConciergeBell size={16} />}
>
Create an Alert
</Button>
<Button
type="primary"
disabled={disabled}
shape="round"
onClick={onAddToDashboard}
icon={<Plus size={16} />}
>
Add to Dashboard
</Button>
{alertButton}
{dashboardButton}
</div>
<div className="actions">
{/* Hide the info icon for metrics explorer until we get the docs link */}
@ -818,9 +931,15 @@ function ExplorerOptions({
destroyOnClose
>
<ExportPanelContainer
query={query}
query={isOneChartPerQuery ? queryToExport : query}
isLoading={isLoading}
onExport={onExport}
onExport={(dashboard, isNewDashboard): void => {
if (isOneChartPerQuery && queryToExport) {
onExport(dashboard, isNewDashboard, queryToExport);
} else {
onExport(dashboard, isNewDashboard);
}
}}
/>
</Modal>
</div>
@ -829,18 +948,26 @@ function ExplorerOptions({
export interface ExplorerOptionsProps {
isLoading?: boolean;
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
onExport: (
dashboard: Dashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void;
query: Query | null;
disabled: boolean;
sourcepage: DataSource;
isExplorerOptionHidden?: boolean;
setIsExplorerOptionHidden?: Dispatch<SetStateAction<boolean>>;
isOneChartPerQuery?: boolean;
splitedQueries?: Query[];
}
ExplorerOptions.defaultProps = {
isLoading: false,
isExplorerOptionHidden: false,
setIsExplorerOptionHidden: undefined,
isOneChartPerQuery: false,
splitedQueries: [],
};
export default ExplorerOptions;

View File

@ -142,6 +142,7 @@ function ChartPreview({
params: {
allowSelectedIntervalForStepGen,
},
originalGraphType: graphType,
},
alertDef?.version || DEFAULT_ENTITY_VERSION,
{

View File

@ -94,6 +94,7 @@ function FullView({
variables: getDashboardVariables(selectedDashboard?.data.variables),
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
originalGraphType: widget?.panelTypes,
};
}
updatedQuery.builder.queryData[0].pageSize = 10;

View File

@ -208,6 +208,7 @@ function GridCardGraph({
: globalSelectedInterval,
start: customTimeRange?.startTime || start,
end: customTimeRange?.endTime || end,
originalGraphType: widget?.panelTypes,
},
version || DEFAULT_ENTITY_VERSION,
{

View File

@ -0,0 +1,228 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getStepIntervalPoints, updateStepInterval } from '../utils';
describe('GridCardLayout Utils', () => {
describe('getStepIntervalPoints', () => {
it('should return 60 points for duration <= 1 hour', () => {
// 30 minutes in milliseconds
const start = Date.now();
const end = start + 30 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(60);
});
it('should return 60 points for exactly 1 hour', () => {
// 1 hour in milliseconds
const start = Date.now();
const end = start + 60 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(60);
});
it('should return 120 points for duration <= 3 hours', () => {
// 2 hours in milliseconds
const start = Date.now();
const end = start + 2 * 60 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(120);
});
it('should return 120 points for exactly 3 hours', () => {
// 3 hours in milliseconds
const start = Date.now();
const end = start + 3 * 60 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(120);
});
it('should return 180 points for duration <= 5 hours', () => {
// 4 hours in milliseconds
const start = Date.now();
const end = start + 4 * 60 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(180);
});
it('should return 180 points for exactly 5 hours', () => {
// 5 hours in milliseconds
const start = Date.now();
const end = start + 5 * 60 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(180);
});
it('should calculate dynamic interval for duration > 5 hours', () => {
// 10 hours in milliseconds
const start = Date.now();
const end = start + 10 * 60 * 60 * 1000;
const result = getStepIntervalPoints(start, end);
// For 10 hours (600 minutes), interval should be ceil(600/80) = 8, rounded to 10, then * 60 = 600
expect(result).toBe(600);
});
it('should handle very long durations correctly', () => {
// 7 days in milliseconds
const start = Date.now();
const end = start + 7 * 24 * 60 * 60 * 1000;
const result = getStepIntervalPoints(start, end);
// For 7 days (10080 minutes), interval should be ceil(10080/80) = 126, rounded to 130, then * 60 = 7800
expect(result).toBe(7800);
});
it('should round up to nearest multiple of 5 minutes', () => {
// 12 hours in milliseconds
const start = Date.now();
const end = start + 12 * 60 * 60 * 1000;
const result = getStepIntervalPoints(start, end);
// For 12 hours (720 minutes), interval should be ceil(720/80) = 9, rounded to 10, then * 60 = 600
expect(result).toBe(600);
});
it('should handle edge case with very small duration', () => {
// 1 minute in milliseconds
const start = Date.now();
const end = start + 1 * 60 * 1000;
expect(getStepIntervalPoints(start, end)).toBe(60);
});
it('should handle zero duration', () => {
const start = Date.now();
const end = start;
expect(getStepIntervalPoints(start, end)).toBe(60);
});
});
describe('updateStepInterval', () => {
const mockQuery: Query = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
stepInterval: 30,
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
queryName: 'A',
aggregateAttribute: { key: 'cpu_usage', type: 'Gauge' },
timeAggregation: 'avg',
spaceAggregation: 'avg',
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
having: [],
groupBy: [],
orderBy: [],
limit: null,
offset: 0,
pageSize: 0,
reduceTo: 'avg',
legend: '',
},
],
queryFormulas: [],
},
clickhouse_sql: [],
promql: [],
id: 'test-query',
};
it('should update stepInterval based on time range', () => {
// 2 hours duration
const minTime = Date.now();
const maxTime = minTime + 2 * 60 * 60 * 1000;
const result = updateStepInterval(mockQuery, minTime, maxTime);
expect(result.builder.queryData[0].stepInterval).toBe(120);
});
it('should preserve other query properties', () => {
const minTime = Date.now();
const maxTime = minTime + 1 * 60 * 60 * 1000;
const result = updateStepInterval(mockQuery, minTime, maxTime);
expect(result.builder.queryData[0].aggregateOperator).toBe('avg');
expect(result.builder.queryData[0].queryName).toBe('A');
expect(result.builder.queryData[0].dataSource).toBe('metrics');
});
it('should handle multiple queryData items', () => {
const multiQueryMock: Query = {
...mockQuery,
builder: {
queryData: [
...mockQuery.builder.queryData,
{
...mockQuery.builder.queryData[0],
queryName: 'B',
stepInterval: 45,
},
],
queryFormulas: [],
},
};
const minTime = Date.now();
const maxTime = minTime + 4 * 60 * 60 * 1000;
const result = updateStepInterval(multiQueryMock, minTime, maxTime);
expect(result.builder.queryData).toHaveLength(2);
expect(result.builder.queryData[0].stepInterval).toBe(180);
expect(result.builder.queryData[1].stepInterval).toBe(180);
});
it('should use calculated stepInterval when original is undefined', () => {
const queryWithUndefinedStep: Query = {
...mockQuery,
builder: {
queryData: [
{
...mockQuery.builder.queryData[0],
stepInterval: undefined as any,
},
],
queryFormulas: [],
},
};
const minTime = Date.now();
const maxTime = minTime + 1 * 60 * 60 * 1000;
const result = updateStepInterval(queryWithUndefinedStep, minTime, maxTime);
expect(result.builder.queryData[0].stepInterval).toBe(60);
});
it('should fallback to 60 when calculated stepInterval is 0', () => {
const minTime = Date.now();
const maxTime = minTime; // Same time = 0 duration
const result = updateStepInterval(mockQuery, minTime, maxTime);
expect(result.builder.queryData[0].stepInterval).toBe(60);
});
it('should handle very large time ranges', () => {
const minTime = Date.now();
const maxTime = minTime + 30 * 24 * 60 * 60 * 1000; // 30 days
const result = updateStepInterval(mockQuery, minTime, maxTime);
// Should calculate appropriate interval for 30 days
expect(result.builder.queryData[0].stepInterval).toBeGreaterThan(180);
});
});
});

View File

@ -2,6 +2,7 @@ import { FORMULA_REGEXP } from 'constants/regExp';
import { isEmpty, isEqual } from 'lodash-es';
import { Layout } from 'react-grid-layout';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
layout.map((obj) =>
@ -51,3 +52,63 @@ export const hasColumnWidthsChanged = (
return !isEqual(newWidths, existingWidths);
});
};
/**
* Calculates the step interval in uPlot points (1 minute = 60 points)
* based on the time duration between two timestamps in nanoseconds.
*
* Conversion logic:
* - <= 1 hr 1 min (60 points)
* - <= 3 hr 2 min (120 points)
* - <= 5 hr 3 min (180 points)
* - > 5 hr max 80 bars, ceil((end-start)/80), rounded to nearest multiple of 5 min
*
* @param startNano - start time in nanoseconds
* @param endNano - end time in nanoseconds
* @returns stepInterval in uPlot points
*/
export function getStepIntervalPoints(
startNano: number,
endNano: number,
): number {
const startMs = startNano;
const endMs = endNano;
const durationMs = endMs - startMs;
const durationMin = durationMs / (60 * 1000); // convert to minutes
if (durationMin <= 60) {
return 60; // 1 min
}
if (durationMin <= 180) {
return 120; // 2 min
}
if (durationMin <= 300) {
return 180; // 3 min
}
const totalPoints = Math.ceil(durationMs / (1000 * 60)); // total minutes
const interval = Math.ceil(totalPoints / 80); // at most 80 bars
const roundedInterval = Math.ceil(interval / 5) * 5; // round up to nearest 5
return roundedInterval * 60; // convert min to points
}
export function updateStepInterval(
query: Query,
minTime: number,
maxTime: number,
): Query {
const stepIntervalPoints = getStepIntervalPoints(minTime, maxTime);
return {
...query,
builder: {
...query.builder,
queryData: [
...query.builder.queryData.map((queryData) => ({
...queryData,
stepInterval: stepIntervalPoints || queryData.stepInterval || 60,
})),
],
},
};
}

View File

@ -19,12 +19,12 @@ function DataSourceInfo({
dataSentToSigNoz: boolean;
isLoading: boolean;
}): JSX.Element {
const { activeLicenseV3 } = useAppContext();
const { activeLicense } = useAppContext();
const notSendingData = !dataSentToSigNoz;
const isEnabled =
activeLicenseV3 && activeLicenseV3.platform === LicensePlatform.CLOUD;
activeLicense && activeLicense.platform === LicensePlatform.CLOUD;
const {
data: deploymentsData,
@ -88,8 +88,8 @@ function DataSourceInfo({
logEvent('Homepage: Connect dataSource clicked', {});
if (
activeLicenseV3 &&
activeLicenseV3.platform === LicensePlatform.CLOUD
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
} else {
@ -105,8 +105,8 @@ function DataSourceInfo({
logEvent('Homepage: Connect dataSource clicked', {});
if (
activeLicenseV3 &&
activeLicenseV3.platform === LicensePlatform.CLOUD
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
} else {

View File

@ -19,6 +19,7 @@ import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData'
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
import { useGetK8sPodsList } from 'hooks/infraMonitoring/useGetK8sPodsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import cloneDeep from 'lodash-es/cloneDeep';
import { CompassIcon, DotIcon, HomeIcon, Plus, Wrench, X } from 'lucide-react';
@ -54,6 +55,8 @@ export default function Home(): JSX.Element {
const [updatingUserPreferences, setUpdatingUserPreferences] = useState(false);
const [loadingUserPreferences, setLoadingUserPreferences] = useState(true);
const { isCommunityUser, isCommunityEnterpriseUser } = useGetTenantLicense();
const [checklistItems, setChecklistItems] = useState<ChecklistItem[]>(
defaultChecklistItemsState,
);
@ -300,17 +303,17 @@ export default function Home(): JSX.Element {
}
}, [hostData, k8sPodsData, handleUpdateChecklistDoneItem]);
const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext();
const { activeLicense, isFetchingActiveLicense } = useAppContext();
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
if (isFetchingActiveLicenseV3) {
if (isFetchingActiveLicense) {
setIsEnabled(false);
return;
}
setIsEnabled(Boolean(activeLicenseV3?.platform === LicensePlatform.CLOUD));
}, [activeLicenseV3, isFetchingActiveLicenseV3]);
setIsEnabled(Boolean(activeLicense?.platform === LicensePlatform.CLOUD));
}, [activeLicense, isFetchingActiveLicense]);
const { data: deploymentsData } = useGetDeploymentsData(isEnabled);
@ -323,22 +326,27 @@ export default function Home(): JSX.Element {
setIsBannerDismissed(true);
};
const showBanner = useMemo(
() => !isBannerDismissed && (isCommunityUser || isCommunityEnterpriseUser),
[isBannerDismissed, isCommunityUser, isCommunityEnterpriseUser],
);
return (
<div className="home-container">
<div className="sticky-header">
{!isBannerDismissed && (
{showBanner && (
<div className="home-container-banner">
<div className="home-container-banner-content">
Big news: SigNoz Cloud Teams plan now starting at just $49/Month -
Big News: SigNoz Community Edition now available with SSO (Google OAuth)
and API keys -
<a
href="https://signoz.io/blog/cloud-teams-plan-now-at-49usd/"
href="https://signoz.io/blog/open-source-signoz-now-available-with-sso-and-api-keys/"
target="_blank"
rel="noreferrer"
className="home-container-banner-link"
>
<i>read more</i>
</a>
🥳🎉
</div>
<div className="home-container-banner-close">

View File

@ -32,7 +32,7 @@ function HomeChecklist({
onSkip: (item: ChecklistItem) => void;
isLoading: boolean;
}): JSX.Element {
const { user, activeLicenseV3 } = useAppContext();
const { user, activeLicense } = useAppContext();
const [completedChecklistItems, setCompletedChecklistItems] = useState<
ChecklistItem[]
@ -94,8 +94,8 @@ function HomeChecklist({
if (item.toRoute !== ROUTES.GET_STARTED_WITH_CLOUD) {
history.push(item.toRoute || '');
} else if (
activeLicenseV3 &&
activeLicenseV3.platform === LicensePlatform.CLOUD
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(item.toRoute || '');
} else {

View File

@ -23,7 +23,7 @@ import { Link } from 'react-router-dom';
import { AppState } from 'store/reducers';
import {
LicensePlatform,
LicenseV3ResModel,
LicenseResModel,
} from 'types/api/licensesV3/getActive';
import { ServicesList } from 'types/api/metrics/getService';
import { GlobalReducer } from 'types/reducer/globalTime';
@ -42,7 +42,7 @@ const EmptyState = memo(
activeLicenseV3,
}: {
user: IUser;
activeLicenseV3: LicenseV3ResModel | null;
activeLicenseV3: LicenseResModel | null;
}): JSX.Element => (
<div className="empty-state-container">
<div className="empty-state-content-container">
@ -146,7 +146,7 @@ function ServiceMetrics({
GlobalReducer
>((state) => state.globalTime);
const { user, activeLicenseV3 } = useAppContext();
const { user, activeLicense } = useAppContext();
const [timeRange, setTimeRange] = useState(() => {
const now = new Date().getTime();
@ -335,7 +335,7 @@ function ServiceMetrics({
{servicesExist ? (
<ServicesListTable services={top5Services} onRowClick={handleRowClick} />
) : (
<EmptyState user={user} activeLicenseV3={activeLicenseV3} />
<EmptyState user={user} activeLicenseV3={activeLicense} />
)}
</Card.Content>

View File

@ -32,7 +32,7 @@ export default function ServiceTraces({
(state) => state.globalTime,
);
const { user, activeLicenseV3 } = useAppContext();
const { user, activeLicense } = useAppContext();
const now = new Date().getTime();
const [timeRange, setTimeRange] = useState({
@ -124,8 +124,8 @@ export default function ServiceTraces({
});
if (
activeLicenseV3 &&
activeLicenseV3.platform === LicensePlatform.CLOUD
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
} else {
@ -160,7 +160,7 @@ export default function ServiceTraces({
</div>
</div>
),
[user?.role, activeLicenseV3],
[user?.role, activeLicense],
);
const renderDashboardsList = useCallback(

View File

@ -8,6 +8,11 @@ import HostMetricDetail from 'components/HostMetricsDetail';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import {
getFiltersFromParams,
getOrderByFromParams,
} from 'container/InfraMonitoringK8s/commonUtils';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@ -15,6 +20,7 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { Filter } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { AppState } from 'store/reducers';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
@ -27,20 +33,51 @@ function HostsList(): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [searchParams, setSearchParams] = useSearchParams();
const [currentPage, setCurrentPage] = useState(1);
const [filters, setFilters] = useState<IBuilderQuery['filters']>({
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
const filters = getFiltersFromParams(
searchParams,
INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS,
);
if (!filters) {
return {
items: [],
op: 'and',
};
}
return filters;
});
const [showFilters, setShowFilters] = useState<boolean>(true);
const [orderBy, setOrderBy] = useState<{
columnName: string;
order: 'asc' | 'desc';
} | null>(null);
} | null>(() => getOrderByFromParams(searchParams));
const [selectedHostName, setSelectedHostName] = useState<string | null>(null);
const handleOrderByChange = (
orderBy: {
columnName: string;
order: 'asc' | 'desc';
} | null,
): void => {
setOrderBy(orderBy);
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(orderBy),
});
};
const [selectedHostName, setSelectedHostName] = useState<string | null>(() => {
const hostName = searchParams.get('hostName');
return hostName || null;
});
const handleHostClick = (hostName: string): void => {
setSelectedHostName(hostName);
setSearchParams({ ...searchParams, hostName });
};
const { pageSize, setPageSize } = usePageSize('hosts');
@ -82,6 +119,10 @@ function HostsList(): JSX.Element {
const isNewFilterAdded = value.items.length !== filters.items.length;
setFilters(value);
handleChangeQueryData('filters', value);
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS]: JSON.stringify(value),
});
if (isNewFilterAdded) {
setCurrentPage(1);
@ -161,7 +202,10 @@ function HostsList(): JSX.Element {
</Button>
</div>
)}
<HostsListControls handleFiltersChange={handleFiltersChange} />
<HostsListControls
filters={filters}
handleFiltersChange={handleFiltersChange}
/>
</div>
<HostsListTable
isLoading={isLoading}
@ -172,10 +216,10 @@ function HostsList(): JSX.Element {
filters={filters}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
setSelectedHostName={setSelectedHostName}
onHostClick={handleHostClick}
pageSize={pageSize}
setPageSize={setPageSize}
setOrderBy={setOrderBy}
setOrderBy={handleOrderByChange}
/>
</div>
</div>

View File

@ -10,8 +10,10 @@ import { DataSource } from 'types/common/queryBuilder';
function HostsListControls({
handleFiltersChange,
filters,
}: {
handleFiltersChange: (value: IBuilderQuery['filters']) => void;
filters: IBuilderQuery['filters'];
}): JSX.Element {
const currentQuery = initialQueriesMap[DataSource.METRICS];
const updatedCurrentQuery = useMemo(
@ -26,11 +28,12 @@ function HostsListControls({
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters,
},
],
},
}),
[currentQuery],
[currentQuery, filters],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;

View File

@ -27,7 +27,7 @@ export default function HostsListTable({
tableData: data,
hostMetricsData,
filters,
setSelectedHostName,
onHostClick,
currentPage,
setCurrentPage,
pageSize,
@ -77,7 +77,7 @@ export default function HostsListTable({
);
const handleRowClick = (record: HostRowData): void => {
setSelectedHostName(record.hostName);
onHostClick(record.hostName);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.ListPage,

View File

@ -41,16 +41,13 @@ export interface HostsListTableProps {
| undefined;
hostMetricsData: HostData[];
filters: TagFilter;
setSelectedHostName: Dispatch<SetStateAction<string | null>>;
onHostClick: (hostName: string) => void;
currentPage: number;
setCurrentPage: Dispatch<SetStateAction<number>>;
pageSize: number;
setOrderBy: Dispatch<
SetStateAction<{
columnName: string;
order: 'asc' | 'desc';
} | null>
>;
setOrderBy: (
orderBy: { columnName: string; order: 'asc' | 'desc' } | null,
) => void;
setPageSize: (pageSize: number) => void;
}

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