From b1c78c2f123cf0c76e09057e3149891cf529f1ff Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Sat, 24 May 2025 19:14:29 +0530 Subject: [PATCH 01/24] feat(license): build license service (#7969) * feat(license): base setup for license service * feat(license): delete old manager and import to new * feat(license): deal with features * feat(license): complete the license service in ee * feat(license): add sqlmigration for licenses * feat(license): remove feature flags * feat(license): refactor into provider pattern * feat(license): remove the ff lookup interface * feat(license): add logging to the validator functions * feat(license): implement features for OSS build * feat(license): fix the OSS build * feat(license): lets blast frontend * feat(license): fix the EE OSS build without license * feat(license): remove the hardcoded testing configs * feat(license): upgrade migration to 34 * feat(license): better naming and structure * feat(license): better naming and structure * feat(license): better naming and structure * feat(license): better naming and structure * feat(license): better naming and structure * feat(license): better naming and structure * feat(license): better naming and structure * feat(license): integration tests * feat(license): integration tests * feat(license): refactor frontend * feat(license): make frontend api structure changes * feat(license): fix integration tests * feat(license): revert hardcoded configs * feat(license): fix integration tests * feat(license): address review comments * feat(license): address review comments * feat(license): address review comments * feat(license): address review comments * feat(license): update migration * feat(license): update migration * feat(license): update migration * feat(license): fixed logging * feat(license): use the unmarshaller for postable subscription * feat(license): correct the error message * feat(license): fix license test * feat(license): fix lint issues * feat(user): do not kill the service if upstream is down --- ee/licensing/config.go | 26 ++ ee/licensing/httplicensing/api.go | 168 ++++++++ ee/licensing/httplicensing/provider.go | 285 +++++++++++++ .../licensingstore/sqllicensingstore/store.go | 186 +++++++++ ee/query-service/app/api/api.go | 55 +-- ee/query-service/app/api/auth.go | 7 +- ee/query-service/app/api/cloudIntegrations.go | 14 +- ee/query-service/app/api/featureFlags.go | 39 +- ee/query-service/app/api/featureFlags_test.go | 38 +- ee/query-service/app/api/gateway.go | 20 +- ee/query-service/app/api/license.go | 167 -------- ee/query-service/app/server.go | 26 +- .../integrations/signozio/signozio.go | 67 --- ee/query-service/license/db.go | 248 ----------- ee/query-service/license/manager.go | 318 -------------- ee/query-service/main.go | 21 +- ee/query-service/model/license.go | 244 ----------- ee/query-service/model/license_test.go | 170 -------- ee/query-service/usage/manager.go | 169 ++++---- frontend/src/AppRoutes/Private.tsx | 32 +- frontend/src/AppRoutes/index.tsx | 47 +-- frontend/src/api/billing/checkout.ts | 29 -- frontend/src/api/billing/manage.ts | 29 -- frontend/src/api/licenses/apply.ts | 26 -- frontend/src/api/licenses/getAll.ts | 18 - frontend/src/api/licensesV3/getActive.ts | 18 - frontend/src/api/v1/checkout/create.ts | 28 ++ frontend/src/api/v1/portal/create.ts | 28 ++ frontend/src/api/v3/licenses/active/get.ts | 25 ++ frontend/src/api/v3/licenses/put.ts | 24 ++ .../ChatSupportGateway/ChatSupportGateway.tsx | 17 +- .../LaunchChatSupport/LaunchChatSupport.tsx | 17 +- frontend/src/container/AppLayout/index.tsx | 67 +-- .../BillingContainer/BillingContainer.tsx | 37 +- .../Home/DataSourceInfo/DataSourceInfo.tsx | 12 +- frontend/src/container/Home/Home.tsx | 8 +- .../Home/HomeChecklist/HomeChecklist.tsx | 6 +- .../Home/Services/ServiceMetrics.tsx | 8 +- .../container/Home/Services/ServiceTraces.tsx | 8 +- .../container/Licenses/ApplyLicenseForm.tsx | 28 +- .../src/container/Licenses/ListLicenses.tsx | 58 --- frontend/src/container/Licenses/index.tsx | 13 +- .../ServiceMetrics/ServiceMetricTable.tsx | 6 +- .../ServiceTraces/ServiceTracesTable.tsx | 6 +- frontend/src/container/SideNav/SideNav.tsx | 30 +- .../useActiveLicenseV3/useActiveLicenseV3.tsx | 12 +- frontend/src/hooks/useGetTenantLicense.ts | 15 +- frontend/src/hooks/useLicense/constant.ts | 8 - frontend/src/hooks/useLicense/index.ts | 6 - frontend/src/hooks/useLicense/useLicense.tsx | 20 - frontend/src/pages/Support/Support.tsx | 17 +- .../WorkspaceAccessRestricted.tsx | 22 +- .../pages/WorkspaceLocked/WorkspaceLocked.tsx | 26 +- .../WorkspaceSuspended/WorkspaceSuspended.tsx | 26 +- frontend/src/providers/App/App.tsx | 90 ++-- frontend/src/providers/App/types.ts | 15 +- frontend/src/tests/test-utils.tsx | 23 +- frontend/src/types/api/billing/checkout.ts | 5 + frontend/src/types/api/licenses/getAll.ts | 5 - .../src/types/api/licensesV3/getActive.ts | 12 +- pkg/licensing/config.go | 18 + pkg/licensing/licensing.go | 55 +++ pkg/licensing/nooplicensing/api.go | 35 ++ pkg/licensing/nooplicensing/provider.go | 99 +++++ pkg/modules/user/impluser/handler.go | 4 +- pkg/query-service/app/http_handler.go | 29 +- .../app/queryBuilder/query_builder.go | 4 +- pkg/query-service/app/server.go | 6 +- pkg/query-service/constants/constants.go | 7 +- pkg/query-service/featureManager/manager.go | 60 --- pkg/query-service/interfaces/featureLookup.go | 13 - pkg/query-service/main.go | 6 + pkg/query-service/model/featureSet.go | 38 -- .../integration/filter_suggestions_test.go | 7 +- .../signoz_cloud_integrations_test.go | 3 - .../integration/signoz_integrations_test.go | 8 +- pkg/signoz/provider.go | 1 + pkg/signoz/signoz.go | 16 + pkg/sqlmigration/034_update_license.go | 149 +++++++ pkg/types/featuretypes/feature.go | 28 ++ pkg/types/invite.go | 4 + pkg/types/license.go | 46 --- pkg/types/licensetypes/license.go | 389 ++++++++++++++++++ pkg/types/licensetypes/license_test.go | 175 ++++++++ .../types/licensetypes/plan.go | 60 +-- pkg/types/licensetypes/subscription.go | 33 ++ tests/integration/fixtures/http.py | 2 + tests/integration/src/bootstrap/c_license.py | 10 +- 88 files changed, 2328 insertions(+), 2172 deletions(-) create mode 100644 ee/licensing/config.go create mode 100644 ee/licensing/httplicensing/api.go create mode 100644 ee/licensing/httplicensing/provider.go create mode 100644 ee/licensing/licensingstore/sqllicensingstore/store.go delete mode 100644 ee/query-service/integrations/signozio/signozio.go delete mode 100644 ee/query-service/license/db.go delete mode 100644 ee/query-service/license/manager.go delete mode 100644 ee/query-service/model/license.go delete mode 100644 ee/query-service/model/license_test.go delete mode 100644 frontend/src/api/billing/checkout.ts delete mode 100644 frontend/src/api/billing/manage.ts delete mode 100644 frontend/src/api/licenses/apply.ts delete mode 100644 frontend/src/api/licenses/getAll.ts delete mode 100644 frontend/src/api/licensesV3/getActive.ts create mode 100644 frontend/src/api/v1/checkout/create.ts create mode 100644 frontend/src/api/v1/portal/create.ts create mode 100644 frontend/src/api/v3/licenses/active/get.ts create mode 100644 frontend/src/api/v3/licenses/put.ts delete mode 100644 frontend/src/container/Licenses/ListLicenses.tsx delete mode 100644 frontend/src/hooks/useLicense/constant.ts delete mode 100644 frontend/src/hooks/useLicense/index.ts delete mode 100644 frontend/src/hooks/useLicense/useLicense.tsx delete mode 100644 frontend/src/types/api/licenses/getAll.ts create mode 100644 pkg/licensing/config.go create mode 100644 pkg/licensing/licensing.go create mode 100644 pkg/licensing/nooplicensing/api.go create mode 100644 pkg/licensing/nooplicensing/provider.go delete mode 100644 pkg/query-service/featureManager/manager.go delete mode 100644 pkg/query-service/interfaces/featureLookup.go delete mode 100644 pkg/query-service/model/featureSet.go create mode 100644 pkg/sqlmigration/034_update_license.go create mode 100644 pkg/types/featuretypes/feature.go delete mode 100644 pkg/types/license.go create mode 100644 pkg/types/licensetypes/license.go create mode 100644 pkg/types/licensetypes/license_test.go rename ee/query-service/model/plans.go => pkg/types/licensetypes/plan.go (61%) create mode 100644 pkg/types/licensetypes/subscription.go diff --git a/ee/licensing/config.go b/ee/licensing/config.go new file mode 100644 index 000000000000..598724d8e363 --- /dev/null +++ b/ee/licensing/config.go @@ -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 +} diff --git a/ee/licensing/httplicensing/api.go b/ee/licensing/httplicensing/api.go new file mode 100644 index 000000000000..9f9bc1f5da98 --- /dev/null +++ b/ee/licensing/httplicensing/api.go @@ -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) +} diff --git a/ee/licensing/httplicensing/provider.go b/ee/licensing/httplicensing/provider.go new file mode 100644 index 000000000000..3764edc7e9b9 --- /dev/null +++ b/ee/licensing/httplicensing/provider.go @@ -0,0 +1,285 @@ +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 { + provider.settings.Logger().DebugContext(ctx, "no organizations found, defaulting to basic plan") + 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 { + provider.settings.Logger().DebugContext(ctx, "license validation started for organizationID", "organizationID", organizationID.StringValue()) + activeLicense, err := provider.GetActive(ctx, organizationID) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + provider.settings.Logger().ErrorContext(ctx, "license validation failed", "organizationID", 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", "organizationID", 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 { + provider.settings.Logger().ErrorContext(ctx, "failed to validate the license with upstream server", "licenseID", activeLicense.Key, "organizationID", organizationID.StringValue()) + + 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", "failureThreshold", provider.config.FailureThreshold, "licenseID", activeLicense.ID.StringValue(), "organizationID", 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") + } + + provider.settings.Logger().DebugContext(ctx, "license validation completed successfully", "licenseID", activeLicense.ID, "organizationID", organizationID.StringValue()) + 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, + }) +} diff --git a/ee/licensing/licensingstore/sqllicensingstore/store.go b/ee/licensing/licensingstore/sqllicensingstore/store.go new file mode 100644 index 000000000000..5167a5fc00d3 --- /dev/null +++ b/ee/licensing/licensingstore/sqllicensingstore/store.go @@ -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 +} diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 812b83b73d4e..a433502c2f1c 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -6,29 +6,29 @@ import ( "net/http/httputil" "time" + "github.com/SigNoz/signoz/ee/licensing/httplicensing" "github.com/SigNoz/signoz/ee/query-service/dao" "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/licensing" "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/types/licensetypes" "github.com/SigNoz/signoz/pkg/version" "github.com/gorilla/mux" "go.uber.org/zap" @@ -40,8 +40,6 @@ type APIHandlerOptions struct { AppDao dao.ModelDao RulesManager *rules.Manager UsageManager *usage.Manager - FeatureFlags baseint.FeatureLookup - LicenseManager *license.Manager IntegrationsController *integrations.Controller CloudIntegrationsController *cloudintegrations.Controller LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController @@ -67,12 +65,12 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, 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), + LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing), FieldsAPI: fields.NewAPI(signoz.TelemetryStore), Signoz: signoz, QuickFilters: quickFilter, @@ -90,18 +88,10 @@ 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 } @@ -114,8 +104,8 @@ 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 } @@ -151,18 +141,17 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { 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) @@ -175,18 +164,14 @@ 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) { +func (ah *APIHandler) updateRequestContext(_ 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") - } + err := ah.Signoz.Licensing.CheckFeature(r.Context(), licensetypes.SSO) + if err != nil && errors.Asc(err, licensing.ErrCodeFeatureUnavailable) { + ssoAvailable = false + } else if err != nil { + zap.L().Error("feature check failed", zap.String("featureKey", licensetypes.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 @@ -199,7 +184,6 @@ func (ah *APIHandler) loginPrecheck(w http.ResponseWriter, r *http.Request) { return } ah.Signoz.Handlers.User.LoginPrecheck(w, r) - return } func (ah *APIHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { @@ -209,7 +193,6 @@ func (ah *APIHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { return } ah.Signoz.Handlers.User.AcceptInvite(w, r) - return } func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) { @@ -219,7 +202,7 @@ func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) { return } ah.Signoz.Handlers.User.GetInvite(w, r) - return + } func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) { diff --git a/ee/query-service/app/api/auth.go b/ee/query-service/app/api/auth.go index 1d90df80d5be..284347394559 100644 --- a/ee/query-service/app/api/auth.go +++ b/ee/query-service/app/api/auth.go @@ -12,8 +12,8 @@ import ( "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/types/licensetypes" ) func parseRequest(r *http.Request, req interface{}) error { @@ -35,7 +35,6 @@ func (ah *APIHandler) loginUser(w http.ResponseWriter, r *http.Request) { return } ah.Signoz.Handlers.User.Login(w, r) - return } func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) { @@ -52,7 +51,7 @@ func (ah *APIHandler) receiveGoogleAuth(w http.ResponseWriter, r *http.Request) redirectUri := constants.GetDefaultSiteURL() ctx := context.Background() - if !ah.CheckFeature(model.SSO) { + if !ah.CheckFeature(r.Context(), licensetypes.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 @@ -118,7 +117,7 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) { redirectUri := constants.GetDefaultSiteURL() ctx := context.Background() - if !ah.CheckFeature(model.SSO) { + if !ah.CheckFeature(r.Context(), licensetypes.SSO) { 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 diff --git a/ee/query-service/app/api/cloudIntegrations.go b/ee/query-service/app/api/cloudIntegrations.go index 1251a5240322..f73e488f281f 100644 --- a/ee/query-service/app/api/cloudIntegrations.go +++ b/ee/query-service/app/api/cloudIntegrations.go @@ -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 } diff --git a/ee/query-service/app/api/featureFlags.go b/ee/query-service/app/api/featureFlags.go index 1feca4b7645b..13572c2f5f20 100644 --- a/ee/query-service/app/api/featureFlags.go +++ b/ee/query-service/app/api/featureFlags.go @@ -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") @@ -116,14 +131,14 @@ func fetchZeusFeatures(url, licenseKey string) (basemodel.FeatureSet, error) { } type ZeusFeaturesResponse struct { - Status string `json:"status"` - Data basemodel.FeatureSet `json:"data"` + Status string `json:"status"` + 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) } diff --git a/ee/query-service/app/api/featureFlags_test.go b/ee/query-service/app/api/featureFlags_test.go index e64e2ea135c7..79032a43a5db 100644 --- a/ee/query-service/app/api/featureFlags_test.go +++ b/ee/query-service/app/api/featureFlags_test.go @@ -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}, diff --git a/ee/query-service/app/api/gateway.go b/ee/query-service/app/api/gateway.go index 54fc1759ed1a..fa1d52153fee 100644 --- a/ee/query-service/app/api/gateway.go +++ b/ee/query-service/app/api/gateway.go @@ -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 } diff --git a/ee/query-service/app/api/license.go b/ee/query-service/app/api/license.go index 8fbbb0cccc62..73b4ae56acef 100644 --- a/ee/query-service/app/api/license.go +++ b/ee/query-service/app/api/license.go @@ -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)) -} diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 3c249cb9389a..9411c05e90a7 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -18,6 +18,7 @@ import ( "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 +31,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" @@ -96,12 +94,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { 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 +160,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(modelDao, 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 } @@ -197,8 +189,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { AppDao: modelDao, RulesManager: rm, UsageManager: usageManager, - FeatureFlags: lm, - LicenseManager: lm, IntegrationsController: integrationsController, CloudIntegrationsController: cloudIntegrationsController, LogsParsingPipelineController: logParsingPipelineController, @@ -431,15 +421,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 +437,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 } diff --git a/ee/query-service/integrations/signozio/signozio.go b/ee/query-service/integrations/signozio/signozio.go deleted file mode 100644 index d1bd76572844..000000000000 --- a/ee/query-service/integrations/signozio/signozio.go +++ /dev/null @@ -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 -} diff --git a/ee/query-service/license/db.go b/ee/query-service/license/db.go deleted file mode 100644 index c241ad876622..000000000000 --- a/ee/query-service/license/db.go +++ /dev/null @@ -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 -} diff --git a/ee/query-service/license/manager.go b/ee/query-service/license/manager.go deleted file mode 100644 index ae5b5c897905..000000000000 --- a/ee/query-service/license/manager.go +++ /dev/null @@ -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 -} diff --git a/ee/query-service/main.go b/ee/query-service/main.go index bb8e0d7e176b..9d11b7f2e96d 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -6,6 +6,8 @@ import ( "os" "time" + "github.com/SigNoz/signoz/ee/licensing" + "github.com/SigNoz/signoz/ee/licensing/httplicensing" eeuserimpl "github.com/SigNoz/signoz/ee/modules/user/impluser" "github.com/SigNoz/signoz/ee/query-service/app" "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" @@ -16,6 +18,7 @@ import ( "github.com/SigNoz/signoz/pkg/config/fileprovider" "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/factory" + pkglicensing "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/modules/user" baseconst "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" @@ -23,6 +26,7 @@ import ( "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 +94,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(), @@ -129,6 +134,10 @@ func main() { config, 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(), @@ -163,22 +172,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)) } diff --git a/ee/query-service/model/license.go b/ee/query-service/model/license.go deleted file mode 100644 index 513d080891ca..000000000000 --- a/ee/query-service/model/license.go +++ /dev/null @@ -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"` -} diff --git a/ee/query-service/model/license_test.go b/ee/query-service/model/license_test.go deleted file mode 100644 index 710541eea3f5..000000000000 --- a/ee/query-service/model/license_test.go +++ /dev/null @@ -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) - } - - } -} diff --git a/ee/query-service/usage/manager.go b/ee/query-service/usage/manager.go index e42946cf9c0e..56341a5b3953 100644 --- a/ee/query-service/usage/manager.go +++ b/ee/query-service/usage/manager.go @@ -15,8 +15,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,64 +36,72 @@ 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(modelDao dao.ModelDao, 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() - // check if license is present or not - license, err := lm.licenseRepo.GetActiveLicense(ctx) +func (lm *Manager) UploadUsage(ctx context.Context) { + + organizations, err := lm.organizationModule.GetAll(context.Background()) if err != nil { - zap.L().Error("failed to get active license", zap.Error(err)) - return - } - if license == nil { - // we will not start the usage reporting if license is not present. - zap.L().Info("no license present, skipping usage reporting") + 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.licenseService.GetActive(ctx, organization.ID) + if err != nil { + zap.L().Error("failed to get active license", zap.Error(err)) + return + } + if license == nil { + // we will not start the usage reporting if license is not present. + zap.L().Info("no license present, skipping usage reporting") + return + } - usages := []model.UsageDB{} + usages := []model.UsageDB{} - // get usage from clickhouse - dbs := []string{"signoz_logs", "signoz_traces", "signoz_metrics"} - query := ` + // get usage from clickhouse + dbs := []string{"signoz_logs", "signoz_traces", "signoz_metrics"} + query := ` SELECT tenant, collector_id, exporter_id, timestamp, data FROM %s.distributed_usage as u1 GLOBAL INNER JOIN @@ -107,76 +116,76 @@ func (lm *Manager) UploadUsage() { order by timestamp ` - for _, db := range dbs { - dbusages := []model.UsageDB{} - err := lm.clickhouseConn.Select(ctx, &dbusages, fmt.Sprintf(query, db, db), time.Now().Add(-(24 * time.Hour))) - if err != nil && !strings.Contains(err.Error(), "doesn't exist") { - zap.L().Error("failed to get usage from clickhouse: %v", zap.Error(err)) - return + for _, db := range dbs { + dbusages := []model.UsageDB{} + err := lm.clickhouseConn.Select(ctx, &dbusages, fmt.Sprintf(query, db, db), time.Now().Add(-(24 * time.Hour))) + if err != nil && !strings.Contains(err.Error(), "doesn't exist") { + zap.L().Error("failed to get usage from clickhouse: %v", zap.Error(err)) + return + } + for _, u := range dbusages { + u.Type = db + usages = append(usages, u) + } } - for _, u := range dbusages { - u.Type = db - usages = append(usages, u) - } - } - if len(usages) <= 0 { - zap.L().Info("no snapshots to upload, skipping.") - return - } - - zap.L().Info("uploading usage data") - - usagesPayload := []model.Usage{} - for _, usage := range usages { - usageDataBytes, err := encryption.Decrypt([]byte(usage.ExporterID[:32]), []byte(usage.Data)) - if err != nil { - zap.L().Error("error while decrypting usage data: %v", zap.Error(err)) + if len(usages) <= 0 { + zap.L().Info("no snapshots to upload, skipping.") return } - usageData := model.Usage{} - err = json.Unmarshal(usageDataBytes, &usageData) - if err != nil { - zap.L().Error("error while unmarshalling usage data: %v", zap.Error(err)) + zap.L().Info("uploading usage data") + + usagesPayload := []model.Usage{} + for _, usage := range usages { + usageDataBytes, err := encryption.Decrypt([]byte(usage.ExporterID[:32]), []byte(usage.Data)) + if err != nil { + zap.L().Error("error while decrypting usage data: %v", zap.Error(err)) + return + } + + usageData := model.Usage{} + err = json.Unmarshal(usageDataBytes, &usageData) + if err != nil { + zap.L().Error("error while unmarshalling usage data: %v", zap.Error(err)) + return + } + + usageData.CollectorID = usage.CollectorID + usageData.ExporterID = usage.ExporterID + usageData.Type = usage.Type + usageData.Tenant = "default" + usageData.OrgName = "default" + usageData.TenantId = "default" + usagesPayload = append(usagesPayload, usageData) + } + + key, _ := uuid.Parse(license.Key) + payload := model.UsagePayload{ + LicenseKey: key, + Usage: usagesPayload, + } + + body, errv2 := json.Marshal(payload) + if errv2 != nil { + zap.L().Error("error while marshalling usage payload: %v", zap.Error(errv2)) return } - usageData.CollectorID = usage.CollectorID - usageData.ExporterID = usage.ExporterID - usageData.Type = usage.Type - usageData.Tenant = "default" - usageData.OrgName = "default" - usageData.TenantId = "default" - usagesPayload = append(usagesPayload, usageData) - } - - key, _ := uuid.Parse(license.Key) - payload := model.UsagePayload{ - LicenseKey: key, - Usage: usagesPayload, - } - - body, errv2 := json.Marshal(payload) - if errv2 != nil { - zap.L().Error("error while marshalling usage payload: %v", zap.Error(errv2)) - return - } - - errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body) - if errv2 != nil { - zap.L().Error("failed to upload usage: %v", zap.Error(errv2)) - // not returning error here since it is captured in the failed count - return + errv2 = lm.zeus.PutMeters(ctx, payload.LicenseKey.String(), body) + if errv2 != nil { + zap.L().Error("failed to upload usage: %v", zap.Error(errv2)) + // 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) } diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index 092fedaae6a9..9c9719694b69 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -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) { diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index e24704a22cb1..e2c693b4bfd1 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -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'; @@ -41,14 +41,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 +65,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 +152,8 @@ function App(): JSX.Element { }, [ hostname, - isFetchingActiveLicenseV3, - activeLicenseV3, + isFetchingActiveLicense, + activeLicense, org, trialInfo?.trialConvertedToSubscription, ], @@ -163,18 +162,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 +202,12 @@ function App(): JSX.Element { }, [ isLoggedInState, user, - licenses, isCloudUser, isEnterpriseSelfHostedUser, - isFetchingLicenses, + isFetchingActiveLicense, isFetchingUser, + activeLicense, + activeLicenseFetchError, ]); useEffect(() => { @@ -231,8 +230,7 @@ function App(): JSX.Element { if ( !isFetchingFeatureFlags && (featureFlags || featureFlagsFetchError) && - licenses && - activeLicenseV3 && + activeLicense && trialInfo ) { let isChatSupportEnabled = false; @@ -270,8 +268,7 @@ function App(): JSX.Element { featureFlags, isFetchingFeatureFlags, featureFlagsFetchError, - licenses, - activeLicenseV3, + activeLicense, trialInfo, isCloudUser, isEnterpriseSelfHostedUser, @@ -333,7 +330,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 ; } @@ -345,7 +342,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 ; } } diff --git a/frontend/src/api/billing/checkout.ts b/frontend/src/api/billing/checkout.ts deleted file mode 100644 index f8eaf397486c..000000000000 --- a/frontend/src/api/billing/checkout.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/billing/manage.ts b/frontend/src/api/billing/manage.ts deleted file mode 100644 index 1ea8fa762d3e..000000000000 --- a/frontend/src/api/billing/manage.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/licenses/apply.ts b/frontend/src/api/licenses/apply.ts deleted file mode 100644 index c691ad836ff3..000000000000 --- a/frontend/src/api/licenses/apply.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/licenses/getAll.ts b/frontend/src/api/licenses/getAll.ts deleted file mode 100644 index b05cdcb9e2c0..000000000000 --- a/frontend/src/api/licenses/getAll.ts +++ /dev/null @@ -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 | ErrorResponse -> => { - const response = await axios.get('/licenses'); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default getAll; diff --git a/frontend/src/api/licensesV3/getActive.ts b/frontend/src/api/licensesV3/getActive.ts deleted file mode 100644 index 48dd0a3a434f..000000000000 --- a/frontend/src/api/licensesV3/getActive.ts +++ /dev/null @@ -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 | ErrorResponse -> => { - const response = await axios.get('/licenses/active'); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; -}; - -export default getActive; diff --git a/frontend/src/api/v1/checkout/create.ts b/frontend/src/api/v1/checkout/create.ts new file mode 100644 index 000000000000..2e71a647686b --- /dev/null +++ b/frontend/src/api/v1/checkout/create.ts @@ -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> => { + try { + const response = await axios.post('/checkout', { + url: props.url, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default updateCreditCardApi; diff --git a/frontend/src/api/v1/portal/create.ts b/frontend/src/api/v1/portal/create.ts new file mode 100644 index 000000000000..1c6854ffe293 --- /dev/null +++ b/frontend/src/api/v1/portal/create.ts @@ -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> => { + try { + const response = await axios.post('/portal', { + url: props.url, + }); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default manageCreditCardApi; diff --git a/frontend/src/api/v3/licenses/active/get.ts b/frontend/src/api/v3/licenses/active/get.ts new file mode 100644 index 000000000000..7bf73e95cad9 --- /dev/null +++ b/frontend/src/api/v3/licenses/active/get.ts @@ -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 +> => { + try { + const response = await axios.get('/licenses/active'); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default getActive; diff --git a/frontend/src/api/v3/licenses/put.ts b/frontend/src/api/v3/licenses/put.ts new file mode 100644 index 000000000000..4cd971acc0e8 --- /dev/null +++ b/frontend/src/api/v3/licenses/put.ts @@ -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> => { + try { + const response = await axios.post('/licenses', { + key: props.key, + }); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default apply; diff --git a/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx b/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx index 94e8de819448..64657c839894 100644 --- a/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx +++ b/frontend/src/components/ChatSupportGateway/ChatSupportGateway.tsx @@ -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, + data: SuccessResponseV2, ): 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(), }); }; diff --git a/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx index e04004a29269..d25e8156eda4 100644 --- a/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx +++ b/frontend/src/components/LaunchChatSupport/LaunchChatSupport.tsx @@ -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, + data: SuccessResponseV2, ): 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(), }); }; diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 03accc56f0b6..e481a4165cdb 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -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, + data: SuccessResponseV2, ): 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 => (
- {activeLicenseV3?.state === LicenseState.TERMINATED && ( + {activeLicense?.state === LicenseState.TERMINATED && ( <> Your SigNoz license is terminated, enterprise features have been disabled. Please contact support at{' '} support@signoz.io for new license )} - {activeLicenseV3?.state === LicenseState.EXPIRED && ( + {activeLicense?.state === LicenseState.EXPIRED && ( <> Your SigNoz license has expired. Please contact support at{' '} support@signoz.io for renewal to @@ -544,7 +545,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { )} - {activeLicenseV3?.state === LicenseState.CANCELLED && ( + {activeLicense?.state === LicenseState.CANCELLED && ( <> Your SigNoz license is cancelled. Please contact support at{' '} support@signoz.io 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{' '} support@signoz.io 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{' '} {getFormattedDateWithMinutes( - dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() || Date.now(), + dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(), )} . diff --git a/frontend/src/container/BillingContainer/BillingContainer.tsx b/frontend/src/container/BillingContainer/BillingContainer.tsx index 9906cace1e2e..e2f4bc847fc0 100644 --- a/frontend/src/container/BillingContainer/BillingContainer.tsx +++ b/frontend/src/container/BillingContainer/BillingContainer.tsx @@ -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(null); const [daysRemaining, setDaysRemaining] = useState(0); const [isFreeTrial, setIsFreeTrial] = useState(false); const [data, setData] = useState([]); @@ -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 = [ @@ -288,11 +279,11 @@ export default function BillingContainer(): JSX.Element { ); const handleBillingOnSuccess = ( - data: ErrorResponse | SuccessResponse, + data: SuccessResponseV2, ): 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(); diff --git a/frontend/src/container/Home/DataSourceInfo/DataSourceInfo.tsx b/frontend/src/container/Home/DataSourceInfo/DataSourceInfo.tsx index 82a94f4c7571..f9d50e06f6a8 100644 --- a/frontend/src/container/Home/DataSourceInfo/DataSourceInfo.tsx +++ b/frontend/src/container/Home/DataSourceInfo/DataSourceInfo.tsx @@ -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 { diff --git a/frontend/src/container/Home/Home.tsx b/frontend/src/container/Home/Home.tsx index 58446801a74b..2150faee7b8f 100644 --- a/frontend/src/container/Home/Home.tsx +++ b/frontend/src/container/Home/Home.tsx @@ -300,17 +300,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); diff --git a/frontend/src/container/Home/HomeChecklist/HomeChecklist.tsx b/frontend/src/container/Home/HomeChecklist/HomeChecklist.tsx index 3bf52945d058..6dab3d07526b 100644 --- a/frontend/src/container/Home/HomeChecklist/HomeChecklist.tsx +++ b/frontend/src/container/Home/HomeChecklist/HomeChecklist.tsx @@ -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 { diff --git a/frontend/src/container/Home/Services/ServiceMetrics.tsx b/frontend/src/container/Home/Services/ServiceMetrics.tsx index 7f1baf92f1b3..d588f9d65d10 100644 --- a/frontend/src/container/Home/Services/ServiceMetrics.tsx +++ b/frontend/src/container/Home/Services/ServiceMetrics.tsx @@ -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 => (
@@ -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 ? ( ) : ( - + )} diff --git a/frontend/src/container/Home/Services/ServiceTraces.tsx b/frontend/src/container/Home/Services/ServiceTraces.tsx index 23d3f613ba49..fd4b0592e169 100644 --- a/frontend/src/container/Home/Services/ServiceTraces.tsx +++ b/frontend/src/container/Home/Services/ServiceTraces.tsx @@ -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({
), - [user?.role, activeLicenseV3], + [user?.role, activeLicense], ); const renderDashboardsList = useCallback( diff --git a/frontend/src/container/Licenses/ApplyLicenseForm.tsx b/frontend/src/container/Licenses/ApplyLicenseForm.tsx index 6b6da7266010..38d774f549f6 100644 --- a/frontend/src/container/Licenses/ApplyLicenseForm.tsx +++ b/frontend/src/container/Licenses/ApplyLicenseForm.tsx @@ -1,8 +1,9 @@ import { Button, Form, Input } from 'antd'; -import apply from 'api/licenses/apply'; +import apply from 'api/v3/licenses/put'; import { useNotifications } from 'hooks/useNotifications'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import APIError from 'types/api/error'; import { requireErrorMessage } from 'utils/form/requireErrorMessage'; import { @@ -36,27 +37,18 @@ function ApplyLicenseForm({ setIsLoading(true); try { - const response = await apply({ + await apply({ key: params.key, }); - - if (response.statusCode === 200) { - await Promise.all([licenseRefetch()]); - - notifications.success({ - message: 'Success', - description: t('license_applied'), - }); - } else { - notifications.error({ - message: 'Error', - description: response.error || t('unexpected_error'), - }); - } + await Promise.all([licenseRefetch()]); + notifications.success({ + message: 'Success', + description: t('license_applied'), + }); } catch (e) { notifications.error({ - message: 'Error', - description: t('unexpected_error'), + message: (e as APIError).getErrorCode(), + description: (e as APIError).getErrorMessage(), }); } setIsLoading(false); diff --git a/frontend/src/container/Licenses/ListLicenses.tsx b/frontend/src/container/Licenses/ListLicenses.tsx deleted file mode 100644 index 45c14bd23884..000000000000 --- a/frontend/src/container/Licenses/ListLicenses.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Typography } from 'antd'; -import { ColumnsType } from 'antd/lib/table'; -import { ResizeTable } from 'components/ResizeTable'; -import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; -import { useTimezone } from 'providers/Timezone'; -import { useTranslation } from 'react-i18next'; -import { License } from 'types/api/licenses/def'; - -function ValidityColumn({ value }: { value: string }): JSX.Element { - const { formatTimezoneAdjustedTimestamp } = useTimezone(); - - return ( - - {formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.ISO_DATETIME_UTC)} - - ); -} - -function ListLicenses({ licenses }: ListLicensesProps): JSX.Element { - const { t } = useTranslation(['licenses']); - - const columns: ColumnsType = [ - { - title: t('column_license_status'), - dataIndex: 'status', - key: 'status', - width: 100, - }, - { - title: t('column_license_key'), - dataIndex: 'key', - key: 'key', - width: 80, - }, - { - title: t('column_valid_from'), - dataIndex: 'ValidFrom', - key: 'valid from', - render: (value: string): JSX.Element => ValidityColumn({ value }), - width: 80, - }, - { - title: t('column_valid_until'), - dataIndex: 'ValidUntil', - key: 'valid until', - render: (value: string): JSX.Element => ValidityColumn({ value }), - width: 80, - }, - ]; - - return ; -} - -interface ListLicensesProps { - licenses: License[]; -} - -export default ListLicenses; diff --git a/frontend/src/container/Licenses/index.tsx b/frontend/src/container/Licenses/index.tsx index 6eeed645e9fd..cae9ad566e67 100644 --- a/frontend/src/container/Licenses/index.tsx +++ b/frontend/src/container/Licenses/index.tsx @@ -4,29 +4,20 @@ import { useAppContext } from 'providers/App/App'; import { useTranslation } from 'react-i18next'; import ApplyLicenseForm from './ApplyLicenseForm'; -import ListLicenses from './ListLicenses'; function Licenses(): JSX.Element { const { t, ready: translationsReady } = useTranslation(['licenses']); - const { licenses, licensesRefetch } = useAppContext(); + const { activeLicenseRefetch } = useAppContext(); if (!translationsReady) { return ; } - const allValidLicense = - licenses?.licenses?.filter((license) => license.isCurrent) || []; - const tabs = [ { label: t('tab_current_license'), key: 'licenses', - children: , - }, - { - label: t('tab_license_history'), - key: 'history', - children: , + children: , }, ]; diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx index 4561d19b85fa..e7fe99e0ae7f 100644 --- a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx @@ -33,7 +33,7 @@ function ServiceMetricTable({ const { notifications } = useNotifications(); const { t: getText } = useTranslation(['services']); - const { isFetchingActiveLicenseV3, trialInfo } = useAppContext(); + const { isFetchingActiveLicense, trialInfo } = useAppContext(); const { isCloudUser: isCloudUserVal } = useGetTenantLicense(); const queries = useGetQueriesRange(queryRangeRequestData, ENTITY_VERSION_V4, { @@ -70,7 +70,7 @@ function ServiceMetricTable({ useEffect(() => { if ( - !isFetchingActiveLicenseV3 && + !isFetchingActiveLicense && trialInfo?.onTrial && !trialInfo?.trialConvertedToSubscription && isCloudUserVal @@ -85,7 +85,7 @@ function ServiceMetricTable({ }, [ services, isCloudUserVal, - isFetchingActiveLicenseV3, + isFetchingActiveLicense, trialInfo?.onTrial, trialInfo?.trialConvertedToSubscription, ]); diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx index c579933920e8..7c4305374f32 100644 --- a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx +++ b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx @@ -21,13 +21,13 @@ function ServiceTraceTable({ const [RPS, setRPS] = useState(0); const { t: getText } = useTranslation(['services']); - const { isFetchingActiveLicenseV3, trialInfo } = useAppContext(); + const { isFetchingActiveLicense, trialInfo } = useAppContext(); const { isCloudUser: isCloudUserVal } = useGetTenantLicense(); const tableColumns = useMemo(() => getColumns(search, false), [search]); useEffect(() => { if ( - !isFetchingActiveLicenseV3 && + !isFetchingActiveLicense && trialInfo?.onTrial && !trialInfo?.trialConvertedToSubscription && isCloudUserVal @@ -42,7 +42,7 @@ function ServiceTraceTable({ }, [ services, isCloudUserVal, - isFetchingActiveLicenseV3, + isFetchingActiveLicense, trialInfo?.onTrial, trialInfo?.trialConvertedToSubscription, ]); diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx index eaef32a9d86d..2603981f146a 100644 --- a/frontend/src/container/SideNav/SideNav.tsx +++ b/frontend/src/container/SideNav/SideNav.tsx @@ -12,7 +12,7 @@ import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts'; import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; import useComponentPermission from 'hooks/useComponentPermission'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; -import { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS } from 'hooks/useLicense'; +import { StatusCodes } from 'http-status-codes'; import history from 'lib/history'; import { AlertTriangle, @@ -26,7 +26,6 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { AppState } from 'store/reducers'; -import { License } from 'types/api/licenses/def'; import AppReducer from 'types/reducer/app'; import { USER_ROLES } from 'types/roles'; import { checkVersionState } from 'utils/app'; @@ -59,7 +58,13 @@ function SideNav(): JSX.Element { AppReducer >((state) => state.app); - const { user, featureFlags, licenses, trialInfo } = useAppContext(); + const { + user, + featureFlags, + trialInfo, + activeLicense, + activeLicenseFetchError, + } = useAppContext(); const isOnboardingV3Enabled = featureFlags?.find( (flag) => flag.name === FeatureKeys.ONBOARDING_V3, @@ -96,14 +101,11 @@ function SideNav(): JSX.Element { const { t } = useTranslation(''); - const licenseStatus: string = - licenses?.licenses?.find((e: License) => e.isCurrent)?.status || ''; + const licenseStatus: string = activeLicense?.status || ''; const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false; - const isLicenseActive = - licenseStatus?.toLocaleLowerCase() === - LICENSE_PLAN_STATUS.VALID.toLocaleLowerCase(); + const isLicenseActive = licenseStatus === 'VALID'; const onClickSignozCloud = (): void => { window.open( @@ -299,10 +301,10 @@ function SideNav(): JSX.Element { } const isOnBasicPlan = - licenses?.licenses?.some( - (license: License) => - license.isCurrent && license.planKey === LICENSE_PLAN_KEY.BASIC_PLAN, - ) || licenses?.licenses === null; + activeLicenseFetchError && + [StatusCodes.NOT_FOUND, StatusCodes.NOT_IMPLEMENTED].includes( + activeLicenseFetchError?.getHttpStatusCode(), + ); if (user.role !== USER_ROLES.ADMIN || isOnBasicPlan) { updatedMenuItems = updatedMenuItems.filter( @@ -347,10 +349,10 @@ function SideNav(): JSX.Element { isEnterpriseSelfHostedUser, isCurrentVersionError, isLatestVersion, - licenses?.licenses, onClickVersionHandler, t, user.role, + activeLicenseFetchError, ]); return ( @@ -443,7 +445,7 @@ function SideNav(): JSX.Element { onClick={onClickShortcuts} /> - {licenses && !isLicenseActive && ( + {!isLicenseActive && ( useQuery({ @@ -11,9 +12,6 @@ const useActiveLicenseV3 = (isLoggedIn: boolean): UseLicense => enabled: !!isLoggedIn, }); -type UseLicense = UseQueryResult< - SuccessResponse | ErrorResponse, - unknown ->; +type UseLicense = UseQueryResult, APIError>; export default useActiveLicenseV3; diff --git a/frontend/src/hooks/useGetTenantLicense.ts b/frontend/src/hooks/useGetTenantLicense.ts index edc99c32e8e7..c241e23b4c56 100644 --- a/frontend/src/hooks/useGetTenantLicense.ts +++ b/frontend/src/hooks/useGetTenantLicense.ts @@ -1,4 +1,3 @@ -import { AxiosError } from 'axios'; import { useAppContext } from 'providers/App/App'; import { LicensePlatform } from 'types/api/licensesV3/getActive'; @@ -8,26 +7,26 @@ export const useGetTenantLicense = (): { isCommunityUser: boolean; isCommunityEnterpriseUser: boolean; } => { - const { activeLicenseV3, activeLicenseV3FetchError } = useAppContext(); + const { activeLicense, activeLicenseFetchError } = useAppContext(); const responsePayload = { - isCloudUser: activeLicenseV3?.platform === LicensePlatform.CLOUD || false, + isCloudUser: activeLicense?.platform === LicensePlatform.CLOUD || false, isEnterpriseSelfHostedUser: - activeLicenseV3?.platform === LicensePlatform.SELF_HOSTED || false, + activeLicense?.platform === LicensePlatform.SELF_HOSTED || false, isCommunityUser: false, isCommunityEnterpriseUser: false, }; if ( - activeLicenseV3FetchError && - (activeLicenseV3FetchError as AxiosError)?.response?.status === 404 + activeLicenseFetchError && + activeLicenseFetchError.getHttpStatusCode() === 404 ) { responsePayload.isCommunityEnterpriseUser = true; } if ( - activeLicenseV3FetchError && - (activeLicenseV3FetchError as AxiosError)?.response?.status === 501 + activeLicenseFetchError && + activeLicenseFetchError.getHttpStatusCode() === 501 ) { responsePayload.isCommunityUser = true; } diff --git a/frontend/src/hooks/useLicense/constant.ts b/frontend/src/hooks/useLicense/constant.ts deleted file mode 100644 index 71134fc08fac..000000000000 --- a/frontend/src/hooks/useLicense/constant.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const LICENSE_PLAN_KEY = { - ENTERPRISE_PLAN: 'ENTERPRISE_PLAN', - BASIC_PLAN: 'BASIC_PLAN', -}; - -export const LICENSE_PLAN_STATUS = { - VALID: 'VALID', -}; diff --git a/frontend/src/hooks/useLicense/index.ts b/frontend/src/hooks/useLicense/index.ts deleted file mode 100644 index 387e93e7d65d..000000000000 --- a/frontend/src/hooks/useLicense/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS } from './constant'; -import useLicense from './useLicense'; - -export default useLicense; - -export { LICENSE_PLAN_KEY, LICENSE_PLAN_STATUS }; diff --git a/frontend/src/hooks/useLicense/useLicense.tsx b/frontend/src/hooks/useLicense/useLicense.tsx deleted file mode 100644 index 89d8ded97427..000000000000 --- a/frontend/src/hooks/useLicense/useLicense.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import getAll from 'api/licenses/getAll'; -import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; -// import { useAppContext } from 'providers/App/App'; -import { useQuery, UseQueryResult } from 'react-query'; -import { ErrorResponse, SuccessResponse } from 'types/api'; -import { PayloadProps } from 'types/api/licenses/getAll'; - -const useLicense = (isLoggedIn: boolean): UseLicense => - useQuery({ - queryFn: getAll, - queryKey: [REACT_QUERY_KEY.GET_ALL_LICENCES], - enabled: !!isLoggedIn, - }); - -type UseLicense = UseQueryResult< - SuccessResponse | ErrorResponse, - unknown ->; - -export default useLicense; diff --git a/frontend/src/pages/Support/Support.tsx b/frontend/src/pages/Support/Support.tsx index edc9066d2fc4..fe6517b70ee7 100644 --- a/frontend/src/pages/Support/Support.tsx +++ b/frontend/src/pages/Support/Support.tsx @@ -1,9 +1,8 @@ import './Support.styles.scss'; import { Button, Card, 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 { FeatureKeys } from 'constants/features'; import { useNotifications } from 'hooks/useNotifications'; import { @@ -18,8 +17,9 @@ import { useAppContext } from 'providers/App/App'; import { useEffect, useState } from 'react'; import { useMutation } from 'react-query'; import { useHistory, 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'; const { Title, Text } = Typography; @@ -109,20 +109,21 @@ export default function Support(): JSX.Element { !isPremiumChatSupportEnabled && !trialInfo?.trialConvertedToSubscription; const handleBillingOnSuccess = ( - data: ErrorResponse | SuccessResponse, + data: SuccessResponseV2, ): 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(), }); }; diff --git a/frontend/src/pages/WorkspaceAccessRestricted/WorkspaceAccessRestricted.tsx b/frontend/src/pages/WorkspaceAccessRestricted/WorkspaceAccessRestricted.tsx index b7e0abd3eefd..a251e4c034fd 100644 --- a/frontend/src/pages/WorkspaceAccessRestricted/WorkspaceAccessRestricted.tsx +++ b/frontend/src/pages/WorkspaceAccessRestricted/WorkspaceAccessRestricted.tsx @@ -8,24 +8,24 @@ import { useEffect } from 'react'; import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive'; function WorkspaceAccessRestricted(): JSX.Element { - const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext(); + const { activeLicense, isFetchingActiveLicense } = useAppContext(); useEffect(() => { - if (!isFetchingActiveLicenseV3) { - const isTerminated = activeLicenseV3?.state === LicenseState.TERMINATED; - const isExpired = activeLicenseV3?.state === LicenseState.EXPIRED; - const isCancelled = activeLicenseV3?.state === LicenseState.CANCELLED; + if (!isFetchingActiveLicense) { + const isTerminated = activeLicense?.state === LicenseState.TERMINATED; + const isExpired = activeLicense?.state === LicenseState.EXPIRED; + const isCancelled = activeLicense?.state === LicenseState.CANCELLED; const isWorkspaceAccessRestricted = isTerminated || isExpired || isCancelled; if ( !isWorkspaceAccessRestricted || - activeLicenseV3.platform === LicensePlatform.SELF_HOSTED + activeLicense.platform === LicensePlatform.SELF_HOSTED ) { history.push(ROUTES.HOME); } } - }, [isFetchingActiveLicenseV3, activeLicenseV3]); + }, [isFetchingActiveLicense, activeLicense]); return (
@@ -44,7 +44,7 @@ function WorkspaceAccessRestricted(): JSX.Element { width="65%" >
- {isFetchingActiveLicenseV3 || !activeLicenseV3 ? ( + {isFetchingActiveLicense || !activeLicense ? ( ) : ( <> @@ -55,7 +55,7 @@ function WorkspaceAccessRestricted(): JSX.Element { level={4} className="workspace-access-restricted__details" > - {activeLicenseV3.state === LicenseState.TERMINATED && ( + {activeLicense.state === LicenseState.TERMINATED && ( <> Your SigNoz license is terminated, please contact support at{' '} @@ -64,7 +64,7 @@ function WorkspaceAccessRestricted(): JSX.Element { for a new deployment )} - {activeLicenseV3.state === LicenseState.EXPIRED && ( + {activeLicense.state === LicenseState.EXPIRED && ( <> Your SigNoz license is expired, please contact support at{' '} @@ -81,7 +81,7 @@ function WorkspaceAccessRestricted(): JSX.Element { . )} - {activeLicenseV3.state === LicenseState.CANCELLED && ( + {activeLicense.state === LicenseState.CANCELLED && ( <> Your SigNoz license is cancelled, please contact support at{' '} diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx index c0cd0877ba67..dc680983ceb8 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -16,8 +16,8 @@ import { Tabs, Typography, } from 'antd'; -import updateCreditCardApi from 'api/billing/checkout'; import logEvent from 'api/common/logEvent'; +import updateCreditCardApi from 'api/v1/checkout/create'; import ROUTES from 'constants/routes'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; @@ -26,6 +26,7 @@ import { useAppContext } from 'providers/App/App'; import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation } from 'react-query'; +import APIError from 'types/api/error'; import { LicensePlatform } from 'types/api/licensesV3/getActive'; import { getFormattedDate } from 'utils/timeUtils'; @@ -41,9 +42,9 @@ import { export default function WorkspaceBlocked(): JSX.Element { const { user, - isFetchingActiveLicenseV3, + isFetchingActiveLicense, trialInfo, - activeLicenseV3, + activeLicense, } = useAppContext(); const isAdmin = user.role === 'ADMIN'; const { notifications } = useNotifications(); @@ -70,37 +71,38 @@ export default function WorkspaceBlocked(): JSX.Element { }; useEffect(() => { - if (!isFetchingActiveLicenseV3) { + if (!isFetchingActiveLicense) { const shouldBlockWorkspace = trialInfo?.workSpaceBlock; if ( !shouldBlockWorkspace || - activeLicenseV3?.platform === LicensePlatform.SELF_HOSTED + activeLicense?.platform === LicensePlatform.SELF_HOSTED ) { history.push(ROUTES.HOME); } } }, [ - isFetchingActiveLicenseV3, + isFetchingActiveLicense, trialInfo?.workSpaceBlock, - activeLicenseV3?.platform, + activeLicense?.platform, ]); const { mutate: updateCreditCard, isLoading } = useMutation( updateCreditCardApi, { onSuccess: (data) => { - 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(); } }, - onError: () => + onError: (error: APIError) => notifications.error({ - message: t('somethingWentWrong'), + message: error.getErrorCode(), + description: error.getErrorMessage(), }), }, ); @@ -320,7 +322,7 @@ export default function WorkspaceBlocked(): JSX.Element { width="65%" >
- {isFetchingActiveLicenseV3 || !trialInfo ? ( + {isFetchingActiveLicense || !trialInfo ? ( ) : ( <> diff --git a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx index 4671527b1054..3633eb7135d5 100644 --- a/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx +++ b/frontend/src/pages/WorkspaceSuspended/WorkspaceSuspended.tsx @@ -10,7 +10,7 @@ import { Space, Typography, } from 'antd'; -import manageCreditCardApi from 'api/billing/manage'; +import manageCreditCardApi from 'api/v1/portal/create'; import ROUTES from 'constants/routes'; import dayjs from 'dayjs'; import { useNotifications } from 'hooks/useNotifications'; @@ -19,6 +19,7 @@ import { useAppContext } from 'providers/App/App'; import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation } from 'react-query'; +import APIError from 'types/api/error'; import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive'; import { getFormattedDateWithMinutes } from 'utils/timeUtils'; @@ -26,7 +27,7 @@ function WorkspaceSuspended(): JSX.Element { const { user } = useAppContext(); const isAdmin = user.role === 'ADMIN'; const { notifications } = useNotifications(); - const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext(); + const { activeLicense, isFetchingActiveLicense } = useAppContext(); const { t } = useTranslation(['failedPayment']); @@ -34,17 +35,18 @@ function WorkspaceSuspended(): JSX.Element { manageCreditCardApi, { onSuccess: (data) => { - 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(); } }, - onError: () => + onError: (error: APIError) => notifications.error({ - message: t('somethingWentWrong'), + message: error.getErrorCode(), + description: error.getErrorMessage(), }), }, ); @@ -56,18 +58,18 @@ function WorkspaceSuspended(): JSX.Element { }, [manageCreditCard]); useEffect(() => { - if (!isFetchingActiveLicenseV3) { + if (!isFetchingActiveLicense) { const shouldSuspendWorkspace = - activeLicenseV3?.state === LicenseState.DEFAULTED; + activeLicense?.state === LicenseState.DEFAULTED; if ( !shouldSuspendWorkspace || - activeLicenseV3?.platform === LicensePlatform.SELF_HOSTED + activeLicense?.platform === LicensePlatform.SELF_HOSTED ) { history.push(ROUTES.HOME); } } - }, [isFetchingActiveLicenseV3, activeLicenseV3]); + }, [isFetchingActiveLicense, activeLicense]); return (
- {isFetchingActiveLicenseV3 || !activeLicenseV3 ? ( + {isFetchingActiveLicense || !activeLicense ? ( ) : ( <> @@ -115,7 +117,7 @@ function WorkspaceSuspended(): JSX.Element { {t('yourDataIsSafe')}{' '} {getFormattedDateWithMinutes( - dayjs(activeLicenseV3?.event_queue?.scheduled_at).unix() || + dayjs(activeLicense?.event_queue?.scheduled_at).unix() || Date.now(), )} {' '} diff --git a/frontend/src/providers/App/App.tsx b/frontend/src/providers/App/App.tsx index 58d4c682f766..8f84ee0184ca 100644 --- a/frontend/src/providers/App/App.tsx +++ b/frontend/src/providers/App/App.tsx @@ -6,7 +6,6 @@ import dayjs from 'dayjs'; import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3'; import useGetFeatureFlag from 'hooks/useGetFeatureFlag'; import { useGlobalEventListener } from 'hooks/useGlobalEventListener'; -import useLicense from 'hooks/useLicense'; import useGetUser from 'hooks/user/useGetUser'; import { createContext, @@ -19,11 +18,10 @@ import { } from 'react'; import { useQuery } from 'react-query'; import { FeatureFlagProps as FeatureFlags } from 'types/api/features/getFeaturesFlags'; -import { PayloadProps as LicensesResModel } from 'types/api/licenses/getAll'; import { LicensePlatform, + LicenseResModel, LicenseState, - LicenseV3ResModel, TrialInfo, } from 'types/api/licensesV3/getActive'; import { Organization } from 'types/api/user/getOrganization'; @@ -38,14 +36,10 @@ export const AppContext = createContext(undefined); export function AppProvider({ children }: PropsWithChildren): JSX.Element { // on load of the provider set the user defaults with access jwt , refresh jwt and user id from local storage const [user, setUser] = useState(() => getUserDefaults()); - const [licenses, setLicenses] = useState(null); - const [ - activeLicenseV3, - setActiveLicenseV3, - ] = useState(null); - + const [activeLicense, setActiveLicense] = useState( + null, + ); const [trialInfo, setTrialInfo] = useState(null); - const [featureFlags, setFeatureFlags] = useState(null); const [orgPreferences, setOrgPreferences] = useState( null, @@ -103,59 +97,40 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { } }, [userData, isFetchingUser]); - // fetcher for licenses v2 - // license will be fetched if we are in logged in state - const { - data: licenseData, - isFetching: isFetchingLicenses, - error: licensesFetchError, - refetch: licensesRefetch, - } = useLicense(isLoggedIn); - useEffect(() => { - if (!isFetchingLicenses && licenseData && licenseData.payload) { - setLicenses(licenseData.payload); - } - }, [licenseData, isFetchingLicenses]); - // fetcher for licenses v3 const { - data: activeLicenseV3Data, - isFetching: isFetchingActiveLicenseV3, - error: activeLicenseV3FetchError, + data: activeLicenseData, + isFetching: isFetchingActiveLicense, + error: activeLicenseFetchError, + refetch: activeLicenseRefetch, } = useActiveLicenseV3(isLoggedIn); useEffect(() => { - if ( - !isFetchingActiveLicenseV3 && - activeLicenseV3Data && - activeLicenseV3Data.payload - ) { - setActiveLicenseV3(activeLicenseV3Data.payload); + if (!isFetchingActiveLicense && activeLicenseData && activeLicenseData.data) { + setActiveLicense(activeLicenseData.data); const isOnTrial = dayjs( - activeLicenseV3Data.payload.free_until || Date.now(), + activeLicenseData.data.free_until || Date.now(), ).isAfter(dayjs()); const trialInfo: TrialInfo = { - trialStart: activeLicenseV3Data.payload.valid_from, - trialEnd: dayjs( - activeLicenseV3Data.payload.free_until || Date.now(), - ).unix(), + trialStart: activeLicenseData.data.valid_from, + trialEnd: dayjs(activeLicenseData.data.free_until || Date.now()).unix(), onTrial: isOnTrial, workSpaceBlock: - activeLicenseV3Data.payload.state === LicenseState.EVALUATION_EXPIRED && - activeLicenseV3Data.payload.platform === LicensePlatform.CLOUD, + activeLicenseData.data.state === LicenseState.EVALUATION_EXPIRED && + activeLicenseData.data.platform === LicensePlatform.CLOUD, trialConvertedToSubscription: - activeLicenseV3Data.payload.state !== LicenseState.ISSUED && - activeLicenseV3Data.payload.state !== LicenseState.EVALUATING && - activeLicenseV3Data.payload.state !== LicenseState.EVALUATION_EXPIRED, + activeLicenseData.data.state !== LicenseState.ISSUED && + activeLicenseData.data.state !== LicenseState.EVALUATING && + activeLicenseData.data.state !== LicenseState.EVALUATION_EXPIRED, gracePeriodEnd: dayjs( - activeLicenseV3Data.payload.event_queue.scheduled_at || Date.now(), + activeLicenseData.data.event_queue.scheduled_at || Date.now(), ).unix(), }; setTrialInfo(trialInfo); } - }, [activeLicenseV3Data, isFetchingActiveLicenseV3]); + }, [activeLicenseData, isFetchingActiveLicense]); // fetcher for feature flags const { @@ -242,9 +217,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { useGlobalEventListener('LOGOUT', () => { setIsLoggedIn(false); setUser(getUserDefaults()); - setActiveLicenseV3(null); + setActiveLicense(null); setTrialInfo(null); - setLicenses(null); setFeatureFlags(null); setOrgPreferences(null); setOrg(null); @@ -254,46 +228,40 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { const value: IAppContext = useMemo( () => ({ user, - licenses, - activeLicenseV3, + activeLicense, featureFlags, trialInfo, orgPreferences, isLoggedIn, org, isFetchingUser, - isFetchingLicenses, - isFetchingActiveLicenseV3, + isFetchingActiveLicense, isFetchingFeatureFlags, isFetchingOrgPreferences, userFetchError, - licensesFetchError, - activeLicenseV3FetchError, + activeLicenseFetchError, featureFlagsFetchError, orgPreferencesFetchError, - licensesRefetch, + activeLicenseRefetch, updateUser, updateOrgPreferences, updateOrg, }), [ trialInfo, - activeLicenseV3, - activeLicenseV3FetchError, + activeLicense, + activeLicenseFetchError, featureFlags, featureFlagsFetchError, - isFetchingActiveLicenseV3, + isFetchingActiveLicense, isFetchingFeatureFlags, - isFetchingLicenses, isFetchingOrgPreferences, isFetchingUser, isLoggedIn, - licenses, - licensesFetchError, - licensesRefetch, org, orgPreferences, orgPreferencesFetchError, + activeLicenseRefetch, updateOrg, user, userFetchError, diff --git a/frontend/src/providers/App/types.ts b/frontend/src/providers/App/types.ts index 8c9d2117dcc6..4dd5b31bf9ea 100644 --- a/frontend/src/providers/App/types.ts +++ b/frontend/src/providers/App/types.ts @@ -1,30 +1,27 @@ +import APIError from 'types/api/error'; import { FeatureFlagProps as FeatureFlags } from 'types/api/features/getFeaturesFlags'; -import { PayloadProps as LicensesResModel } from 'types/api/licenses/getAll'; -import { LicenseV3ResModel, TrialInfo } from 'types/api/licensesV3/getActive'; +import { LicenseResModel, TrialInfo } from 'types/api/licensesV3/getActive'; import { Organization } from 'types/api/user/getOrganization'; import { UserResponse as User } from 'types/api/user/getUser'; import { OrgPreference } from 'types/reducer/app'; export interface IAppContext { user: IUser; - licenses: LicensesResModel | null; - activeLicenseV3: LicenseV3ResModel | null; + activeLicense: LicenseResModel | null; trialInfo: TrialInfo | null; featureFlags: FeatureFlags[] | null; orgPreferences: OrgPreference[] | null; isLoggedIn: boolean; org: Organization[] | null; isFetchingUser: boolean; - isFetchingLicenses: boolean; - isFetchingActiveLicenseV3: boolean; + isFetchingActiveLicense: boolean; isFetchingFeatureFlags: boolean; isFetchingOrgPreferences: boolean; userFetchError: unknown; - licensesFetchError: unknown; - activeLicenseV3FetchError: unknown; + activeLicenseFetchError: APIError | null; featureFlagsFetchError: unknown; orgPreferencesFetchError: unknown; - licensesRefetch: () => void; + activeLicenseRefetch: () => void; updateUser: (user: IUser) => void; updateOrgPreferences: (orgPreferences: OrgPreference[]) => void; updateOrg(orgId: string, updatedOrgName: string): void; diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index 71f253afe587..cdbe9bdd6db9 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -105,7 +105,8 @@ export function getAppContextMock( appContextOverrides?: Partial, ): IAppContext { return { - activeLicenseV3: { + activeLicense: { + key: 'test-key', event_queue: { created_at: '0', event: LicenseEvent.NO_EVENT, @@ -138,8 +139,8 @@ export function getAppContextMock( trialConvertedToSubscription: false, gracePeriodEnd: -1, }, - isFetchingActiveLicenseV3: false, - activeLicenseV3FetchError: null, + isFetchingActiveLicense: false, + activeLicenseFetchError: null, user: { accessJwt: 'some-token', refreshJwt: 'some-refresh-token', @@ -160,20 +161,6 @@ export function getAppContextMock( ], isFetchingUser: false, userFetchError: null, - licenses: { - licenses: [ - { - key: 'does-not-matter', - isCurrent: true, - planKey: 'ENTERPRISE_PLAN', - ValidFrom: new Date(), - ValidUntil: new Date(), - status: 'VALID', - }, - ], - }, - isFetchingLicenses: false, - licensesFetchError: null, featureFlags: [ { name: FeatureKeys.SSO, @@ -246,7 +233,7 @@ export function getAppContextMock( updateUser: jest.fn(), updateOrg: jest.fn(), updateOrgPreferences: jest.fn(), - licensesRefetch: jest.fn(), + activeLicenseRefetch: jest.fn(), ...appContextOverrides, }; } diff --git a/frontend/src/types/api/billing/checkout.ts b/frontend/src/types/api/billing/checkout.ts index 78523376f01e..4b1a2311ca37 100644 --- a/frontend/src/types/api/billing/checkout.ts +++ b/frontend/src/types/api/billing/checkout.ts @@ -5,3 +5,8 @@ export interface CheckoutSuccessPayloadProps { export interface CheckoutRequestPayloadProps { url: string; } + +export interface PayloadProps { + data: CheckoutSuccessPayloadProps; + status: string; +} diff --git a/frontend/src/types/api/licenses/getAll.ts b/frontend/src/types/api/licenses/getAll.ts deleted file mode 100644 index 58996cf36e3d..000000000000 --- a/frontend/src/types/api/licenses/getAll.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { License } from './def'; - -export type PayloadProps = { - licenses: License[]; -}; diff --git a/frontend/src/types/api/licensesV3/getActive.ts b/frontend/src/types/api/licensesV3/getActive.ts index b073438bad90..a26d76606443 100644 --- a/frontend/src/types/api/licensesV3/getActive.ts +++ b/frontend/src/types/api/licensesV3/getActive.ts @@ -30,7 +30,7 @@ export const LicensePlanKey = { BASIC: 'BASIC', }; -export type LicenseV3EventQueueResModel = { +export type LicenseEventQueueResModel = { event: LicenseEvent; status: string; scheduled_at: string; @@ -38,10 +38,11 @@ export type LicenseV3EventQueueResModel = { updated_at: string; }; -export type LicenseV3ResModel = { +export type LicenseResModel = { + key: string; status: LicenseStatus; state: LicenseState; - event_queue: LicenseV3EventQueueResModel; + event_queue: LicenseEventQueueResModel; platform: LicensePlatform; created_at: string; plan: { @@ -67,3 +68,8 @@ export type TrialInfo = { trialConvertedToSubscription: boolean; gracePeriodEnd: number; }; + +export interface PayloadProps { + data: LicenseEventQueueResModel; + status: string; +} diff --git a/pkg/licensing/config.go b/pkg/licensing/config.go new file mode 100644 index 000000000000..a88480d8679d --- /dev/null +++ b/pkg/licensing/config.go @@ -0,0 +1,18 @@ +package licensing + +import ( + "time" + + "github.com/SigNoz/signoz/pkg/factory" +) + +var _ factory.Config = (*Config)(nil) + +type Config struct { + PollInterval time.Duration `mapstructure:"poll_interval"` + FailureThreshold int `mapstructure:"failure_threshold"` +} + +func (c Config) Validate() error { + return nil +} diff --git a/pkg/licensing/licensing.go b/pkg/licensing/licensing.go new file mode 100644 index 000000000000..0e0196650fda --- /dev/null +++ b/pkg/licensing/licensing.go @@ -0,0 +1,55 @@ +package licensing + +import ( + "context" + "net/http" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/types/featuretypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +var ( + ErrCodeUnsupported = errors.MustNewCode("licensing_unsupported") + ErrCodeFeatureUnavailable = errors.MustNewCode("feature_unavailable") +) + +type Licensing interface { + factory.Service + + // Validate validates the license with the upstream server + Validate(ctx context.Context) error + // Activate validates and enables the license + Activate(ctx context.Context, organizationID valuer.UUID, key string) error + // GetActive fetches the current active license in org + GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) + // Refresh refreshes the license state from upstream server + Refresh(ctx context.Context, organizationID valuer.UUID) error + // Checkout creates a checkout session via upstream server and returns the redirection link + Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) + // Portal creates a portal session via upstream server and return the redirection link + Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) + + // feature surrogate + // CheckFeature checks if the feature is active or not + CheckFeature(ctx context.Context, key string) error + // GetFeatureFlags fetches all the defined feature flags + GetFeatureFlag(ctx context.Context, key string) (*featuretypes.GettableFeature, error) + // GetFeatureFlags fetches all the defined feature flags + GetFeatureFlags(ctx context.Context) ([]*featuretypes.GettableFeature, error) + // InitFeatures initialises the feature flags + InitFeatures(ctx context.Context, features []*featuretypes.GettableFeature) error + // UpdateFeatureFlag updates the feature flag + UpdateFeatureFlag(ctx context.Context, feature *featuretypes.GettableFeature) error +} + +type API interface { + Activate(http.ResponseWriter, *http.Request) + Refresh(http.ResponseWriter, *http.Request) + GetActive(http.ResponseWriter, *http.Request) + + Checkout(http.ResponseWriter, *http.Request) + Portal(http.ResponseWriter, *http.Request) +} diff --git a/pkg/licensing/nooplicensing/api.go b/pkg/licensing/nooplicensing/api.go new file mode 100644 index 000000000000..e484376fd567 --- /dev/null +++ b/pkg/licensing/nooplicensing/api.go @@ -0,0 +1,35 @@ +package nooplicensing + +import ( + "net/http" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/licensing" +) + +type noopLicensingAPI struct{} + +func NewLicenseAPI() licensing.API { + return &noopLicensingAPI{} +} + +func (api *noopLicensingAPI) Activate(rw http.ResponseWriter, r *http.Request) { + render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) +} + +func (api *noopLicensingAPI) GetActive(rw http.ResponseWriter, r *http.Request) { + render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) +} + +func (api *noopLicensingAPI) Refresh(rw http.ResponseWriter, r *http.Request) { + render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) +} + +func (api *noopLicensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) { + render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) +} + +func (api *noopLicensingAPI) Portal(rw http.ResponseWriter, r *http.Request) { + render.Error(rw, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "not implemented")) +} diff --git a/pkg/licensing/nooplicensing/provider.go b/pkg/licensing/nooplicensing/provider.go new file mode 100644 index 000000000000..0e509615f246 --- /dev/null +++ b/pkg/licensing/nooplicensing/provider.go @@ -0,0 +1,99 @@ +package nooplicensing + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/types/featuretypes" + "github.com/SigNoz/signoz/pkg/types/licensetypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type noopLicensing struct { + stopChan chan struct{} +} + +func NewFactory() factory.ProviderFactory[licensing.Licensing, licensing.Config] { + return factory.NewProviderFactory(factory.MustNewName("noop"), func(ctx context.Context, providerSettings factory.ProviderSettings, config licensing.Config) (licensing.Licensing, error) { + return New(ctx, providerSettings, config) + }) +} + +func New(_ context.Context, _ factory.ProviderSettings, _ licensing.Config) (licensing.Licensing, error) { + return &noopLicensing{stopChan: make(chan struct{})}, nil +} + +func (provider *noopLicensing) Start(context.Context) error { + <-provider.stopChan + return nil + +} + +func (provider *noopLicensing) Stop(context.Context) error { + close(provider.stopChan) + return nil +} + +func (provider *noopLicensing) Activate(ctx context.Context, organizationID valuer.UUID, key string) error { + return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching license is not supported") +} + +func (provider *noopLicensing) Validate(ctx context.Context) error { + return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "validating license is not supported") +} + +func (provider *noopLicensing) Refresh(ctx context.Context, organizationID valuer.UUID) error { + return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "refreshing license is not supported") +} + +func (provider *noopLicensing) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) { + return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "checkout session is not supported") +} + +func (provider *noopLicensing) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) { + return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "portal session is not supported") +} + +func (provider *noopLicensing) GetActive(ctx context.Context, organizationID valuer.UUID) (*licensetypes.License, error) { + return nil, errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "fetching active license is not supported") +} + +func (provider *noopLicensing) CheckFeature(ctx context.Context, key string) error { + feature, err := provider.GetFeatureFlag(ctx, key) + if err != nil { + return err + } + + if feature.Active { + return nil + } + + return errors.Newf(errors.TypeNotFound, licensing.ErrCodeFeatureUnavailable, "feature unavailable: %s", key) +} + +func (provider *noopLicensing) GetFeatureFlag(ctx context.Context, key string) (*featuretypes.GettableFeature, error) { + features, err := provider.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + for _, feature := range features { + if feature.Name == key { + return feature, nil + } + } + return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "no feature available with given key: %s", key) +} + +func (provider *noopLicensing) GetFeatureFlags(ctx context.Context) ([]*featuretypes.GettableFeature, error) { + return licensetypes.DefaultFeatureSet, nil +} + +func (provider *noopLicensing) InitFeatures(ctx context.Context, features []*featuretypes.GettableFeature) error { + return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "init features is not supported") +} + +func (provider *noopLicensing) UpdateFeatureFlag(ctx context.Context, feature *featuretypes.GettableFeature) error { + return errors.New(errors.TypeUnsupported, licensing.ErrCodeUnsupported, "updating feature flag is not supported") +} diff --git a/pkg/modules/user/impluser/handler.go b/pkg/modules/user/impluser/handler.go index cfc867d87ad6..66d43e2b1da9 100644 --- a/pkg/modules/user/impluser/handler.go +++ b/pkg/modules/user/impluser/handler.go @@ -92,7 +92,7 @@ func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) { return } - _, err = h.module.CreateBulkInvite(ctx, claims.OrgID, claims.UserID, &types.PostableBulkInviteRequest{ + invites, err := h.module.CreateBulkInvite(ctx, claims.OrgID, claims.UserID, &types.PostableBulkInviteRequest{ Invites: []types.PostableInvite{req}, }) if err != nil { @@ -100,7 +100,7 @@ func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) { return } - render.Success(rw, http.StatusCreated, nil) + render.Success(rw, http.StatusCreated, invites[0]) } func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) { diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 15a1882e1c0d..4caad03633d2 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -23,6 +23,7 @@ import ( errorsV2 "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/licensing" "github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" @@ -58,6 +59,7 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/postprocess" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types/featuretypes" "github.com/SigNoz/signoz/pkg/types/pipelinetypes" ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes" @@ -89,7 +91,6 @@ func NewRouter() *mux.Router { type APIHandler struct { reader interfaces.Reader ruleManager *rules.Manager - featureFlags interfaces.FeatureLookup querier interfaces.Querier querierV2 interfaces.Querier queryBuilder *queryBuilder.QueryBuilder @@ -136,6 +137,8 @@ type APIHandler struct { AlertmanagerAPI *alertmanager.API + LicensingAPI licensing.API + FieldsAPI *fields.API Signoz *signoz.SigNoz @@ -155,9 +158,6 @@ type APIHandlerOpts struct { // rule manager handles rule crud operations RuleManager *rules.Manager - // feature flags querier - FeatureFlags interfaces.FeatureLookup - // Integrations IntegrationsController *integrations.Controller @@ -177,6 +177,8 @@ type APIHandlerOpts struct { AlertmanagerAPI *alertmanager.API + LicensingAPI licensing.API + FieldsAPI *fields.API Signoz *signoz.SigNoz @@ -224,7 +226,6 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { preferSpanMetrics: opts.PreferSpanMetrics, temporalityMap: make(map[string]map[v3.Temporality]bool), ruleManager: opts.RuleManager, - featureFlags: opts.FeatureFlags, IntegrationsController: opts.IntegrationsController, CloudIntegrationsController: opts.CloudIntegrationsController, LogsParsingPipelineController: opts.LogsParsingPipelineController, @@ -244,6 +245,7 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { JWT: opts.JWT, SummaryService: summaryService, AlertmanagerAPI: opts.AlertmanagerAPI, + LicensingAPI: opts.LicensingAPI, Signoz: opts.Signoz, FieldsAPI: opts.FieldsAPI, QuickFilters: opts.QuickFilters, @@ -607,7 +609,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { render.Success(rw, http.StatusOK, []any{}) })).Methods(http.MethodGet) router.HandleFunc("/api/v3/licenses/active", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) { - render.Error(rw, errorsV2.New(errorsV2.TypeUnsupported, errorsV2.CodeUnsupported, "not implemented")) + aH.LicensingAPI.Activate(rw, req) })).Methods(http.MethodGet) } @@ -1979,15 +1981,14 @@ func (aH *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) { } func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { - featureSet, err := aH.FF().GetFeatureFlags() + featureSet, err := aH.Signoz.Licensing.GetFeatureFlags(r.Context()) if err != nil { aH.HandleError(w, err, http.StatusInternalServerError) return } if aH.preferSpanMetrics { - for idx := range featureSet { - feature := &featureSet[idx] - if feature.Name == model.UseSpanMetrics { + for idx, feature := range featureSet { + if feature.Name == featuretypes.UseSpanMetrics { featureSet[idx].Active = true } } @@ -1995,12 +1996,8 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { aH.Respond(w, featureSet) } -func (aH *APIHandler) FF() interfaces.FeatureLookup { - return aH.featureFlags -} - -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 } diff --git a/pkg/query-service/app/queryBuilder/query_builder.go b/pkg/query-service/app/queryBuilder/query_builder.go index f49a04693730..2a9aa2a5e5fd 100644 --- a/pkg/query-service/app/queryBuilder/query_builder.go +++ b/pkg/query-service/app/queryBuilder/query_builder.go @@ -8,7 +8,6 @@ import ( "github.com/SigNoz/signoz/pkg/cache" metricsV3 "github.com/SigNoz/signoz/pkg/query-service/app/metrics/v3" "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/query-service/interfaces" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "go.uber.org/zap" ) @@ -46,8 +45,7 @@ type prepareLogsQueryFunc func(start, end int64, queryType v3.QueryType, panelTy type prepareMetricQueryFunc func(start, end int64, queryType v3.QueryType, panelType v3.PanelType, bq *v3.BuilderQuery, options metricsV3.Options) (string, error) type QueryBuilder struct { - options QueryBuilderOptions - featureFlags interfaces.FeatureLookup + options QueryBuilderOptions } type QueryBuilderOptions struct { diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index 0ccadb1e8c68..08a26c87991e 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -14,6 +14,7 @@ import ( "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/apis/fields" "github.com/SigNoz/signoz/pkg/http/middleware" + "github.com/SigNoz/signoz/pkg/licensing/nooplicensing" "github.com/SigNoz/signoz/pkg/modules/quickfilter" quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/prometheus" @@ -34,7 +35,6 @@ import ( "github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/query-service/featureManager" "github.com/SigNoz/signoz/pkg/query-service/healthcheck" "github.com/SigNoz/signoz/pkg/query-service/interfaces" "github.com/SigNoz/signoz/pkg/query-service/rules" @@ -81,8 +81,6 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status { // NewServer creates and initializes Server func NewServer(serverOptions *ServerOptions) (*Server, error) { - // initiate feature manager - fm := featureManager.StartManager() fluxIntervalForTraceDetail, err := time.ParseDuration(serverOptions.FluxIntervalForTraceDetail) if err != nil { @@ -146,13 +144,13 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { Reader: reader, PreferSpanMetrics: serverOptions.PreferSpanMetrics, RuleManager: rm, - FeatureFlags: fm, IntegrationsController: integrationsController, CloudIntegrationsController: cloudIntegrationsController, LogsParsingPipelineController: logParsingPipelineController, FluxInterval: fluxInterval, JWT: serverOptions.Jwt, AlertmanagerAPI: alertmanager.NewAPI(serverOptions.SigNoz.Alertmanager), + LicensingAPI: nooplicensing.NewLicenseAPI(), FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore), Signoz: serverOptions.SigNoz, QuickFilters: quickFilter, diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index 21850a53accd..d1446bbe12ce 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -9,6 +9,7 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/model" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" + "github.com/SigNoz/signoz/pkg/types/featuretypes" ) const ( @@ -65,9 +66,9 @@ func UseMetricsPreAggregation() bool { var KafkaSpanEval = GetOrDefaultEnv("KAFKA_SPAN_EVAL", "false") -var DEFAULT_FEATURE_SET = model.FeatureSet{ - model.Feature{ - Name: model.UseSpanMetrics, +var DEFAULT_FEATURE_SET = []*featuretypes.GettableFeature{ + &featuretypes.GettableFeature{ + Name: featuretypes.UseSpanMetrics, Active: false, Usage: 0, UsageLimit: -1, diff --git a/pkg/query-service/featureManager/manager.go b/pkg/query-service/featureManager/manager.go deleted file mode 100644 index 7805fe619147..000000000000 --- a/pkg/query-service/featureManager/manager.go +++ /dev/null @@ -1,60 +0,0 @@ -package featureManager - -import ( - "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/query-service/model" - "go.uber.org/zap" -) - -type FeatureManager struct { -} - -func StartManager() *FeatureManager { - fM := &FeatureManager{} - return fM -} - -// CheckFeature will be internally used by backend routines -// for feature gating -func (fm *FeatureManager) CheckFeature(featureKey string) error { - - feature, err := fm.GetFeatureFlag(featureKey) - if err != nil { - return err - } - - if feature.Active { - return nil - } - - return model.ErrFeatureUnavailable{Key: featureKey} -} - -// GetFeatureFlags returns current features -func (fm *FeatureManager) GetFeatureFlags() (model.FeatureSet, error) { - features := constants.DEFAULT_FEATURE_SET - return features, nil -} - -func (fm *FeatureManager) InitFeatures(req model.FeatureSet) error { - zap.L().Error("InitFeatures not implemented in OSS") - return nil -} - -func (fm *FeatureManager) UpdateFeatureFlag(req model.Feature) error { - zap.L().Error("UpdateFeatureFlag not implemented in OSS") - return nil -} - -func (fm *FeatureManager) GetFeatureFlag(key string) (model.Feature, error) { - features, err := fm.GetFeatureFlags() - if err != nil { - return model.Feature{}, err - } - for _, feature := range features { - if feature.Name == key { - return feature, nil - } - } - return model.Feature{}, model.ErrFeatureUnavailable{Key: key} -} diff --git a/pkg/query-service/interfaces/featureLookup.go b/pkg/query-service/interfaces/featureLookup.go deleted file mode 100644 index e2ecbcc3bbbb..000000000000 --- a/pkg/query-service/interfaces/featureLookup.go +++ /dev/null @@ -1,13 +0,0 @@ -package interfaces - -import ( - "github.com/SigNoz/signoz/pkg/query-service/model" -) - -type FeatureLookup interface { - CheckFeature(f string) error - GetFeatureFlags() (model.FeatureSet, error) - GetFeatureFlag(f string) (model.Feature, error) - UpdateFeatureFlag(features model.Feature) error - InitFeatures(features model.FeatureSet) error -} diff --git a/pkg/query-service/main.go b/pkg/query-service/main.go index 7c4ba52dbb81..89644b85189e 100644 --- a/pkg/query-service/main.go +++ b/pkg/query-service/main.go @@ -11,6 +11,8 @@ import ( "github.com/SigNoz/signoz/pkg/config/fileprovider" "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/licensing" + "github.com/SigNoz/signoz/pkg/licensing/nooplicensing" "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" @@ -120,6 +122,10 @@ func main() { config, zeus.Config{}, noopzeus.NewProviderFactory(), + licensing.Config{}, + func(_ sqlstore.SQLStore, _ zeus.Zeus) factory.ProviderFactory[licensing.Licensing, licensing.Config] { + return nooplicensing.NewFactory() + }, signoz.NewEmailingProviderFactories(), signoz.NewCacheProviderFactories(), signoz.NewWebProviderFactories(), diff --git a/pkg/query-service/model/featureSet.go b/pkg/query-service/model/featureSet.go deleted file mode 100644 index 4646d030f6e6..000000000000 --- a/pkg/query-service/model/featureSet.go +++ /dev/null @@ -1,38 +0,0 @@ -package model - -type FeatureSet []Feature -type Feature struct { - Name string `db:"name" json:"name"` - Active bool `db:"active" json:"active"` - Usage int64 `db:"usage" json:"usage"` - UsageLimit int64 `db:"usage_limit" json:"usage_limit"` - Route string `db:"route" json:"route"` -} - -const UseSpanMetrics = "USE_SPAN_METRICS" -const AnomalyDetection = "ANOMALY_DETECTION" -const TraceFunnels = "TRACE_FUNNELS" - -var BasicPlan = FeatureSet{ - Feature{ - Name: UseSpanMetrics, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - Feature{ - Name: AnomalyDetection, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, - Feature{ - Name: TraceFunnels, - Active: false, - Usage: 0, - UsageLimit: -1, - Route: "", - }, -} diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index 781e453cdf6d..b0e02b1ec450 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -24,7 +24,6 @@ import ( "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/query-service/featureManager" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "github.com/SigNoz/signoz/pkg/query-service/utils" "github.com/SigNoz/signoz/pkg/signoz" @@ -304,7 +303,6 @@ func (tb *FilterSuggestionsTestBed) GetQBFilterSuggestionsForLogs( func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { testDB := utils.NewQueryServiceDBForTests(t) - fm := featureManager.StartManager() reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) @@ -317,9 +315,8 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ - Reader: reader, - FeatureFlags: fm, - JWT: jwt, + Reader: reader, + JWT: jwt, Signoz: &signoz.SigNoz{ Modules: modules, Handlers: signoz.NewHandlers(modules, userHandler), diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index 0d3a93ac1d24..76ea8f590f91 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -24,7 +24,6 @@ import ( "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations" - "github.com/SigNoz/signoz/pkg/query-service/featureManager" "github.com/SigNoz/signoz/pkg/query-service/utils" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" @@ -366,7 +365,6 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI t.Fatalf("could not create cloud integrations controller: %v", err) } - fm := featureManager.StartManager() reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) @@ -382,7 +380,6 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, CloudIntegrationsController: controller, - FeatureFlags: fm, JWT: jwt, Signoz: &signoz.SigNoz{ Modules: modules, diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index 4111d6df421c..8751ef5c2aa5 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -22,7 +22,6 @@ import ( "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/featureManager" "github.com/SigNoz/signoz/pkg/query-service/model" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "github.com/SigNoz/signoz/pkg/query-service/utils" @@ -567,7 +566,6 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration t.Fatalf("could not create integrations controller: %v", err) } - fm := featureManager.StartManager() reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) @@ -587,9 +585,9 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ - Reader: reader, - IntegrationsController: controller, - FeatureFlags: fm, + Reader: reader, + IntegrationsController: controller, + JWT: jwt, CloudIntegrationsController: cloudIntegrationsController, Signoz: &signoz.SigNoz{ diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index a1aed180c96c..d5363edd680c 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -80,6 +80,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM sqlmigration.NewCreateQuickFiltersFactory(sqlstore), sqlmigration.NewUpdateQuickFiltersFactory(sqlstore), sqlmigration.NewAuthRefactorFactory(sqlstore), + sqlmigration.NewUpdateLicenseFactory(sqlstore), sqlmigration.NewMigratePATToFactorAPIKey(sqlstore), ) } diff --git a/pkg/signoz/signoz.go b/pkg/signoz/signoz.go index d72a1c18cd5c..555b03a4d3a3 100644 --- a/pkg/signoz/signoz.go +++ b/pkg/signoz/signoz.go @@ -8,6 +8,7 @@ import ( "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/instrumentation" + "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/sqlmigration" @@ -30,6 +31,7 @@ type SigNoz struct { Prometheus prometheus.Prometheus Alertmanager alertmanager.Alertmanager Zeus zeus.Zeus + Licensing licensing.Licensing Emailing emailing.Emailing Modules Modules Handlers Handlers @@ -40,6 +42,8 @@ func New( config Config, zeusConfig zeus.Config, zeusProviderFactory factory.ProviderFactory[zeus.Zeus, zeus.Config], + licenseConfig licensing.Config, + licenseProviderFactoryCb func(sqlstore.SQLStore, zeus.Zeus) factory.ProviderFactory[licensing.Licensing, licensing.Config], emailingProviderFactories factory.NamedMap[factory.ProviderFactory[emailing.Emailing, emailing.Config]], cacheProviderFactories factory.NamedMap[factory.ProviderFactory[cache.Cache, cache.Config]], webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]], @@ -171,6 +175,16 @@ func New( return nil, err } + licensingProviderFactory := licenseProviderFactoryCb(sqlstore, zeus) + licensing, err := licensingProviderFactory.New( + ctx, + providerSettings, + licenseConfig, + ) + if err != nil { + return nil, err + } + userModule := userModuleFactory(sqlstore, emailing, providerSettings) userHandler := userHandlerFactory(userModule) @@ -184,6 +198,7 @@ func New( instrumentation.Logger(), factory.NewNamedService(factory.MustNewName("instrumentation"), instrumentation), factory.NewNamedService(factory.MustNewName("alertmanager"), alertmanager), + factory.NewNamedService(factory.MustNewName("licensing"), licensing), ) if err != nil { return nil, err @@ -199,6 +214,7 @@ func New( Prometheus: prometheus, Alertmanager: alertmanager, Zeus: zeus, + Licensing: licensing, Emailing: emailing, Modules: modules, Handlers: handlers, diff --git a/pkg/sqlmigration/034_update_license.go b/pkg/sqlmigration/034_update_license.go new file mode 100644 index 000000000000..0be8ab82bdf4 --- /dev/null +++ b/pkg/sqlmigration/034_update_license.go @@ -0,0 +1,149 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +type updateLicense struct { + store sqlstore.SQLStore +} + +type existingLicense34 struct { + bun.BaseModel `bun:"table:licenses_v3"` + + ID string `bun:"id,pk,type:text"` + Key string `bun:"key,type:text,notnull,unique"` + Data string `bun:"data,type:text"` +} + +type newLicense34 struct { + bun.BaseModel `bun:"table:license"` + + types.Identifiable + types.TimeAuditable + Key string `bun:"key,type:text,notnull,unique"` + Data map[string]any `bun:"data,type:text"` + LastValidatedAt time.Time `bun:"last_validated_at,notnull"` + OrgID string `bun:"org_id,type:text,notnull" json:"orgID"` +} + +func NewUpdateLicenseFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("update_license"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return newUpdateLicense(ctx, ps, c, store) + }) +} + +func newUpdateLicense(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) { + return &updateLicense{store: store}, nil +} + +func (migration *updateLicense) Register(migrations *migrate.Migrations) error { + if err := migrations.Register(migration.Up, migration.Down); err != nil { + return err + } + + return nil +} + +func (migration *updateLicense) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer func() { + _ = tx.Rollback() + }() + + err = migration.store.Dialect().RenameTableAndModifyModel(ctx, tx, new(existingLicense34), new(newLicense34), []string{OrgReference}, func(ctx context.Context) error { + existingLicenses := make([]*existingLicense34, 0) + err = tx.NewSelect().Model(&existingLicenses).Scan(ctx) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + if err == nil && len(existingLicenses) > 0 { + var orgID string + err := migration. + store. + BunDB(). + NewSelect(). + Model((*types.Organization)(nil)). + Column("id"). + Scan(ctx, &orgID) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + if err == nil { + newLicenses, err := migration.CopyExistingLicensesToNewLicenses(existingLicenses, orgID) + if err != nil { + return err + } + _, err = tx. + NewInsert(). + Model(&newLicenses). + Exec(ctx) + if err != nil { + return err + } + } + return nil + } + return nil + }) + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (migration *updateLicense) Down(context.Context, *bun.DB) error { + return nil +} + +func (migration *updateLicense) CopyExistingLicensesToNewLicenses(existingLicenses []*existingLicense34, orgID string) ([]*newLicense34, error) { + newLicenses := make([]*newLicense34, len(existingLicenses)) + for idx, existingLicense := range existingLicenses { + licenseID, err := valuer.NewUUID(existingLicense.ID) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "license id is not a valid UUID: %s", existingLicense.ID) + } + licenseData := map[string]any{} + err = json.Unmarshal([]byte(existingLicense.Data), &licenseData) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "unable to unmarshal license data in map[string]any") + } + newLicenses[idx] = &newLicense34{ + Identifiable: types.Identifiable{ + ID: licenseID, + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + Key: existingLicense.Key, + Data: licenseData, + LastValidatedAt: time.Now(), + OrgID: orgID, + } + } + return newLicenses, nil +} diff --git a/pkg/types/featuretypes/feature.go b/pkg/types/featuretypes/feature.go new file mode 100644 index 000000000000..964cd4a15c64 --- /dev/null +++ b/pkg/types/featuretypes/feature.go @@ -0,0 +1,28 @@ +package featuretypes + +import "github.com/uptrace/bun" + +type FeatureSet []*GettableFeature +type GettableFeature struct { + Name string `db:"name" json:"name"` + Active bool `db:"active" json:"active"` + Usage int64 `db:"usage" json:"usage"` + UsageLimit int64 `db:"usage_limit" json:"usage_limit"` + Route string `db:"route" json:"route"` +} + +type StorableFeature struct { + bun.BaseModel `bun:"table:feature_status"` + + Name string `bun:"name,pk,type:text" json:"name"` + Active bool `bun:"active" json:"active"` + Usage int `bun:"usage,default:0" json:"usage"` + UsageLimit int `bun:"usage_limit,default:0" json:"usage_limit"` + Route string `bun:"route,type:text" json:"route"` +} + +func NewStorableFeature() {} + +const UseSpanMetrics = "USE_SPAN_METRICS" +const AnomalyDetection = "ANOMALY_DETECTION" +const TraceFunnels = "TRACE_FUNNELS" diff --git a/pkg/types/invite.go b/pkg/types/invite.go index 40fea73d27c7..8de9ede3498c 100644 --- a/pkg/types/invite.go +++ b/pkg/types/invite.go @@ -85,3 +85,7 @@ type PostableInvite struct { type PostableBulkInviteRequest struct { Invites []PostableInvite `json:"invites"` } + +type GettableCreateInviteResponse struct { + InviteToken string `json:"token"` +} diff --git a/pkg/types/license.go b/pkg/types/license.go deleted file mode 100644 index fb9fd7e6d081..000000000000 --- a/pkg/types/license.go +++ /dev/null @@ -1,46 +0,0 @@ -package types - -import ( - "time" - - "github.com/uptrace/bun" -) - -type License struct { - bun.BaseModel `bun:"table:licenses"` - - Key string `bun:"key,pk,type:text"` - CreatedAt time.Time `bun:"createdAt,default:current_timestamp"` - UpdatedAt time.Time `bun:"updatedAt,default:current_timestamp"` - PlanDetails string `bun:"planDetails,type:text"` - ActivationID string `bun:"activationId,type:text"` - ValidationMessage string `bun:"validationMessage,type:text"` - LastValidated time.Time `bun:"lastValidated,default:current_timestamp"` -} - -type Site struct { - bun.BaseModel `bun:"table:sites"` - - UUID string `bun:"uuid,pk,type:text"` - Alias string `bun:"alias,type:varchar(180),default:'PROD'"` - URL string `bun:"url,type:varchar(300)"` - CreatedAt time.Time `bun:"createdAt,default:current_timestamp"` -} - -type FeatureStatus struct { - bun.BaseModel `bun:"table:feature_status"` - - Name string `bun:"name,pk,type:text" json:"name"` - Active bool `bun:"active" json:"active"` - Usage int `bun:"usage,default:0" json:"usage"` - UsageLimit int `bun:"usage_limit,default:0" json:"usage_limit"` - Route string `bun:"route,type:text" json:"route"` -} - -type LicenseV3 struct { - bun.BaseModel `bun:"table:licenses_v3"` - - ID string `bun:"id,pk,type:text"` - Key string `bun:"key,type:text,notnull,unique"` - Data string `bun:"data,type:text"` -} diff --git a/pkg/types/licensetypes/license.go b/pkg/types/licensetypes/license.go new file mode 100644 index 000000000000..994b2c7b63be --- /dev/null +++ b/pkg/types/licensetypes/license.go @@ -0,0 +1,389 @@ +package licensetypes + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/featuretypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" +) + +type StorableLicense struct { + bun.BaseModel `bun:"table:license"` + + types.Identifiable + types.TimeAuditable + Key string `bun:"key,type:text,notnull,unique"` + Data map[string]any `bun:"data,type:text"` + LastValidatedAt time.Time `bun:"last_validated_at,notnull"` + OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgID"` +} + +// this data excludes ID and Key +type License struct { + ID valuer.UUID + Key string + Data map[string]interface{} + PlanName string + Features []*featuretypes.GettableFeature + Status string + ValidFrom int64 + ValidUntil int64 + CreatedAt time.Time + UpdatedAt time.Time + LastValidatedAt time.Time + OrganizationID valuer.UUID +} + +type GettableLicense map[string]any + +type PostableLicense struct { + Key string `json:"key"` +} + +func NewStorableLicense(ID valuer.UUID, key string, data map[string]any, createdAt, updatedAt, lastValidatedAt time.Time, organizationID valuer.UUID) *StorableLicense { + return &StorableLicense{ + Identifiable: types.Identifiable{ + ID: ID, + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + Key: key, + Data: data, + LastValidatedAt: lastValidatedAt, + OrgID: organizationID, + } +} + +func NewStorableLicenseFromLicense(license *License) *StorableLicense { + return &StorableLicense{ + Identifiable: types.Identifiable{ + ID: license.ID, + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: license.CreatedAt, + UpdatedAt: license.UpdatedAt, + }, + Key: license.Key, + Data: license.Data, + LastValidatedAt: license.LastValidatedAt, + OrgID: license.OrganizationID, + } +} + +func GetActiveLicenseFromStorableLicenses(storableLicenses []*StorableLicense, organizationID valuer.UUID) (*License, error) { + var activeLicense *License + for _, storableLicense := range storableLicenses { + license, err := NewLicenseFromStorableLicense(storableLicense) + if err != nil { + return nil, err + } + + if license.Status != "VALID" { + continue + } + if activeLicense == nil && + (license.ValidFrom != 0) && + (license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) { + activeLicense = license + } + if activeLicense != nil && + license.ValidFrom > activeLicense.ValidFrom && + (license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) { + activeLicense = license + } + } + + if activeLicense == nil { + return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "no active license found for the organization %s", organizationID.StringValue()) + } + + return activeLicense, nil +} + +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 NewLicense(data []byte, organizationID valuer.UUID) (*License, error) { + licenseData := map[string]any{} + err := json.Unmarshal(data, &licenseData) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to unmarshal license data") + } + + var features []*featuretypes.GettableFeature + + // extract id from data + licenseIDStr, err := extractKeyFromMapStringInterface[string](licenseData, "id") + if err != nil { + return nil, err + } + licenseID, err := valuer.NewUUID(licenseIDStr) + if err != nil { + return nil, err + } + delete(licenseData, "id") + + // extract key from data + licenseKey, err := extractKeyFromMapStringInterface[string](licenseData, "key") + if err != nil { + return nil, err + } + delete(licenseData, "key") + + // extract status from data + status, err := extractKeyFromMapStringInterface[string](licenseData, "status") + if err != nil { + return nil, err + } + + planMap, err := extractKeyFromMapStringInterface[map[string]any](licenseData, "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 := make([]*featuretypes.GettableFeature, 0) + if _features, ok := licenseData["features"]; ok { + featuresData, err := json.Marshal(_features) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal features data") + } + + if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "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 + } + } + } + licenseData["features"] = features + + _validFrom, err := extractKeyFromMapStringInterface[float64](licenseData, "valid_from") + if err != nil { + _validFrom = 0 + } + validFrom := int64(_validFrom) + + _validUntil, err := extractKeyFromMapStringInterface[float64](licenseData, "valid_until") + if err != nil { + _validUntil = 0 + } + validUntil := int64(_validUntil) + + return &License{ + ID: licenseID, + Key: licenseKey, + Data: licenseData, + PlanName: planName, + Features: features, + ValidFrom: validFrom, + ValidUntil: validUntil, + Status: status, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + LastValidatedAt: time.Now(), + OrganizationID: organizationID, + }, nil + +} + +func NewLicenseFromStorableLicense(storableLicense *StorableLicense) (*License, error) { + var features []*featuretypes.GettableFeature + // extract status from data + status, err := extractKeyFromMapStringInterface[string](storableLicense.Data, "status") + if err != nil { + return nil, err + } + + planMap, err := extractKeyFromMapStringInterface[map[string]any](storableLicense.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 := make([]*featuretypes.GettableFeature, 0) + if _features, ok := storableLicense.Data["features"]; ok { + featuresData, err := json.Marshal(_features) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal features data") + } + + if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "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 + } + } + } + storableLicense.Data["features"] = features + + _validFrom, err := extractKeyFromMapStringInterface[float64](storableLicense.Data, "valid_from") + if err != nil { + _validFrom = 0 + } + validFrom := int64(_validFrom) + + _validUntil, err := extractKeyFromMapStringInterface[float64](storableLicense.Data, "valid_until") + if err != nil { + _validUntil = 0 + } + validUntil := int64(_validUntil) + + return &License{ + ID: storableLicense.ID, + Key: storableLicense.Key, + Data: storableLicense.Data, + PlanName: planName, + Features: features, + ValidFrom: validFrom, + ValidUntil: validUntil, + Status: status, + CreatedAt: storableLicense.CreatedAt, + UpdatedAt: storableLicense.UpdatedAt, + LastValidatedAt: storableLicense.LastValidatedAt, + OrganizationID: storableLicense.OrgID, + }, nil + +} + +func (license *License) Update(data []byte) error { + updatedLicense, err := NewLicense(data, license.OrganizationID) + if err != nil { + return err + } + + currentTime := time.Now() + license.Data = updatedLicense.Data + license.Features = updatedLicense.Features + license.ID = updatedLicense.ID + license.Key = updatedLicense.Key + license.PlanName = updatedLicense.PlanName + license.Status = updatedLicense.Status + license.ValidFrom = updatedLicense.ValidFrom + license.ValidUntil = updatedLicense.ValidUntil + license.UpdatedAt = currentTime + license.LastValidatedAt = currentTime + + return nil +} + +func NewGettableLicense(data map[string]any, key string) *GettableLicense { + gettableLicense := make(GettableLicense) + for k, v := range data { + gettableLicense[k] = v + } + gettableLicense["key"] = key + return &gettableLicense +} + +func (p *PostableLicense) UnmarshalJSON(data []byte) error { + var postableLicense struct { + Key string `json:"key"` + } + + err := json.Unmarshal(data, &postableLicense) + if err != nil { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal payload") + } + + if postableLicense.Key == "" { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "license key cannot be empty") + } + + p.Key = postableLicense.Key + return nil +} + +type Store interface { + Create(context.Context, *StorableLicense) error + Get(context.Context, valuer.UUID, valuer.UUID) (*StorableLicense, error) + GetAll(context.Context, valuer.UUID) ([]*StorableLicense, error) + Update(context.Context, valuer.UUID, *StorableLicense) error + + // feature surrogate + InitFeatures(context.Context, []*featuretypes.StorableFeature) error + CreateFeature(context.Context, *featuretypes.StorableFeature) error + GetFeature(context.Context, string) (*featuretypes.StorableFeature, error) + GetAllFeatures(context.Context) ([]*featuretypes.StorableFeature, error) + UpdateFeature(context.Context, *featuretypes.StorableFeature) error + + // ListOrganizations returns the list of orgs + ListOrganizations(context.Context) ([]valuer.UUID, error) +} diff --git a/pkg/types/licensetypes/license_test.go b/pkg/types/licensetypes/license_test.go new file mode 100644 index 000000000000..b2216accbb60 --- /dev/null +++ b/pkg/types/licensetypes/license_test.go @@ -0,0 +1,175 @@ +package licensetypes + +import ( + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/types/featuretypes" + "github.com/SigNoz/signoz/pkg/valuer" + "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 *License + 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":"0196f794-ff30-7bee-a5f4-ef5ad315715e"}`), + pass: false, + error: errors.New("key key is missing"), + }, + { + name: "Error for invalid string license key", + data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":10}`), + pass: false, + error: errors.New("key key is not a valid string"), + }, + { + name: "Error for missing license status", + data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e", "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":"0196f794-ff30-7bee-a5f4-ef5ad315715e","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":"0196f794-ff30-7bee-a5f4-ef5ad315715e","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":"0196f794-ff30-7bee-a5f4-ef5ad315715e","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":"0196f794-ff30-7bee-a5f4-ef5ad315715e","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":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`), + pass: true, + expected: &License{ + ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), + 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", + Features: make([]*featuretypes.GettableFeature, 0), + OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), + }, + }, + { + name: "Fallback to basic plan if license status is invalid", + data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"INVALID","plan":{"name":"ENTERPRISE"},"valid_from": 1730899309,"valid_until": -1}`), + pass: true, + expected: &License{ + ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), + 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", + Features: make([]*featuretypes.GettableFeature, 0), + OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), + }, + }, + { + name: "fallback states for validFrom and validUntil", + data: []byte(`{"id":"0196f794-ff30-7bee-a5f4-ef5ad315715e","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"ENTERPRISE"},"valid_from":1234.456,"valid_until":5678.567}`), + pass: true, + expected: &License{ + ID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), + 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", + Features: make([]*featuretypes.GettableFeature, 0), + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + LastValidatedAt: time.Time{}, + OrganizationID: valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e"), + }, + }, + } + + for _, tc := range testCases { + license, err := NewLicense(tc.data, valuer.MustNewUUID("0196f794-ff30-7bee-a5f4-ef5ad315715e")) + if license != nil { + license.Features = make([]*featuretypes.GettableFeature, 0) + delete(license.Data, "features") + } + + if tc.pass { + require.NoError(t, err) + require.NotNil(t, license) + // as the new license will pick the time.Now() value. doesn't make sense to compare them + license.CreatedAt = time.Time{} + license.UpdatedAt = time.Time{} + license.LastValidatedAt = time.Time{} + assert.Equal(t, tc.expected, license) + } else { + require.Error(t, err) + assert.EqualError(t, err, tc.error.Error()) + require.Nil(t, license) + } + + } +} diff --git a/ee/query-service/model/plans.go b/pkg/types/licensetypes/plan.go similarity index 61% rename from ee/query-service/model/plans.go rename to pkg/types/licensetypes/plan.go index 2de2e7ccb87e..452101808488 100644 --- a/ee/query-service/model/plans.go +++ b/pkg/types/licensetypes/plan.go @@ -1,8 +1,6 @@ -package model +package licensetypes -import ( - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" -) +import "github.com/SigNoz/signoz/pkg/types/featuretypes" const SSO = "SSO" const Basic = "BASIC_PLAN" @@ -26,44 +24,44 @@ const ChatSupport = "CHAT_SUPPORT" const Gateway = "GATEWAY" const PremiumSupport = "PREMIUM_SUPPORT" -var BasicPlan = basemodel.FeatureSet{ - basemodel.Feature{ +var BasicPlan = featuretypes.FeatureSet{ + &featuretypes.GettableFeature{ Name: SSO, Active: false, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ - Name: basemodel.UseSpanMetrics, + &featuretypes.GettableFeature{ + Name: featuretypes.UseSpanMetrics, Active: false, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ + &featuretypes.GettableFeature{ Name: Gateway, Active: false, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ + &featuretypes.GettableFeature{ Name: PremiumSupport, Active: false, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ - Name: basemodel.AnomalyDetection, + &featuretypes.GettableFeature{ + Name: featuretypes.AnomalyDetection, Active: false, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ - Name: basemodel.TraceFunnels, + &featuretypes.GettableFeature{ + Name: featuretypes.TraceFunnels, Active: false, Usage: 0, UsageLimit: -1, @@ -71,58 +69,68 @@ var BasicPlan = basemodel.FeatureSet{ }, } -var EnterprisePlan = basemodel.FeatureSet{ - basemodel.Feature{ +var EnterprisePlan = featuretypes.FeatureSet{ + &featuretypes.GettableFeature{ Name: SSO, Active: true, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ - Name: basemodel.UseSpanMetrics, + &featuretypes.GettableFeature{ + Name: featuretypes.UseSpanMetrics, Active: false, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ + &featuretypes.GettableFeature{ Name: Onboarding, Active: true, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ + &featuretypes.GettableFeature{ Name: ChatSupport, Active: true, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ + &featuretypes.GettableFeature{ Name: Gateway, Active: true, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ + &featuretypes.GettableFeature{ Name: PremiumSupport, Active: true, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ - Name: basemodel.AnomalyDetection, + &featuretypes.GettableFeature{ + Name: featuretypes.AnomalyDetection, Active: true, Usage: 0, UsageLimit: -1, Route: "", }, - basemodel.Feature{ - Name: basemodel.TraceFunnels, + &featuretypes.GettableFeature{ + Name: featuretypes.TraceFunnels, + Active: false, + Usage: 0, + UsageLimit: -1, + Route: "", + }, +} + +var DefaultFeatureSet = featuretypes.FeatureSet{ + &featuretypes.GettableFeature{ + Name: featuretypes.UseSpanMetrics, Active: false, Usage: 0, UsageLimit: -1, diff --git a/pkg/types/licensetypes/subscription.go b/pkg/types/licensetypes/subscription.go new file mode 100644 index 000000000000..9065a8a46558 --- /dev/null +++ b/pkg/types/licensetypes/subscription.go @@ -0,0 +1,33 @@ +package licensetypes + +import ( + "encoding/json" + + "github.com/SigNoz/signoz/pkg/errors" +) + +type GettableSubscription struct { + RedirectURL string `json:"redirectURL"` +} + +type PostableSubscription struct { + SuccessURL string `json:"url"` +} + +func (p *PostableSubscription) UnmarshalJSON(data []byte) error { + var postableSubscription struct { + SuccessURL string `json:"url"` + } + + err := json.Unmarshal(data, &postableSubscription) + if err != nil { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to unmarshal payload") + } + + if postableSubscription.SuccessURL == "" { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "success url cannot be empty") + } + + p.SuccessURL = postableSubscription.SuccessURL + return nil +} diff --git a/tests/integration/fixtures/http.py b/tests/integration/fixtures/http.py index fd8f798df484..2df0f1cc305c 100644 --- a/tests/integration/fixtures/http.py +++ b/tests/integration/fixtures/http.py @@ -6,6 +6,7 @@ from testcontainers.core.container import Network from wiremock.client import ( Mapping, Mappings, + Requests, ) from wiremock.constants import Config from wiremock.testing.testcontainer import WireMockContainer @@ -78,3 +79,4 @@ def make_http_mocks(): yield _make_http_mocks Mappings.delete_all_mappings() + Requests.reset_request_journal() diff --git a/tests/integration/src/bootstrap/c_license.py b/tests/integration/src/bootstrap/c_license.py index 5a2647610f90..6ed524d1d8e5 100644 --- a/tests/integration/src/bootstrap/c_license.py +++ b/tests/integration/src/bootstrap/c_license.py @@ -69,7 +69,7 @@ def test_apply_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None: timeout=5, ) - assert response.json()["count"] >= 1 + assert response.json()["count"] == 1 def test_refresh_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None: @@ -123,7 +123,7 @@ def test_refresh_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None cursor = signoz.sqlstore.conn.cursor() cursor.execute( - "SELECT data FROM licenses_v3 WHERE id='0196360e-90cd-7a74-8313-1aa815ce2a67'" + "SELECT data FROM license WHERE id='0196360e-90cd-7a74-8313-1aa815ce2a67'" ) record = cursor.fetchone()[0] assert json.loads(record)["valid_from"] == 1732146922 @@ -134,7 +134,7 @@ def test_refresh_license(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None timeout=5, ) - assert response.json()["count"] >= 1 + assert response.json()["count"] == 1 def test_license_checkout(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None: @@ -172,7 +172,7 @@ def test_license_checkout(signoz: SigNoz, make_http_mocks, get_jwt_token) -> Non timeout=5, ) - assert response.status_code == http.HTTPStatus.OK + assert response.status_code == http.HTTPStatus.CREATED assert response.json()["data"]["redirectURL"] == "https://signoz.checkout.com" response = requests.post( @@ -219,7 +219,7 @@ def test_license_portal(signoz: SigNoz, make_http_mocks, get_jwt_token) -> None: timeout=5, ) - assert response.status_code == http.HTTPStatus.OK + assert response.status_code == http.HTTPStatus.CREATED assert response.json()["data"]["redirectURL"] == "https://signoz.portal.com" response = requests.post( From 93ca3fee333d6388f78634f67bc60c4c4352b75d Mon Sep 17 00:00:00 2001 From: Vibhu Pandey Date: Sat, 24 May 2025 22:00:12 +0530 Subject: [PATCH 02/24] fix(quickfilter): fix injection of quickfilter (#8031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📄 Summary fix injection of quickfilter --- ee/query-service/app/api/api.go | 6 ---- .../{api.go => implquickfilter/handler.go} | 32 ++++++++--------- .../core.go => implquickfilter/module.go} | 34 +++++++++---------- .../{core => implquickfilter}/store.go | 3 +- .../{usecase.go => quickfilter.go} | 10 +++++- pkg/query-service/app/http_handler.go | 19 +++-------- pkg/query-service/app/server.go | 6 ---- pkg/query-service/auth/auth.go | 2 +- .../integration/filter_suggestions_test.go | 4 --- .../integration/logparsingpipeline_test.go | 4 --- .../signoz_cloud_integrations_test.go | 4 --- .../integration/signoz_integrations_test.go | 6 ---- pkg/signoz/handler.go | 4 +++ pkg/signoz/module.go | 4 +++ pkg/types/quickfiltertypes/filter.go | 5 +-- 15 files changed, 58 insertions(+), 85 deletions(-) rename pkg/modules/quickfilter/{api.go => implquickfilter/handler.go} (58%) rename pkg/modules/quickfilter/{core/core.go => implquickfilter/module.go} (70%) rename pkg/modules/quickfilter/{core => implquickfilter}/store.go (98%) rename pkg/modules/quickfilter/{usecase.go => quickfilter.go} (73%) diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index a433502c2f1c..3d5729156118 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -17,8 +17,6 @@ import ( "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/licensing" - "github.com/SigNoz/signoz/pkg/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" @@ -59,8 +57,6 @@ 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, @@ -73,8 +69,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing), FieldsAPI: fields.NewAPI(signoz.TelemetryStore), Signoz: signoz, - QuickFilters: quickFilter, - QuickFilterModule: quickfiltermodule, }) if err != nil { diff --git a/pkg/modules/quickfilter/api.go b/pkg/modules/quickfilter/implquickfilter/handler.go similarity index 58% rename from pkg/modules/quickfilter/api.go rename to pkg/modules/quickfilter/implquickfilter/handler.go index add2c4c3b631..65d0477a281c 100644 --- a/pkg/modules/quickfilter/api.go +++ b/pkg/modules/quickfilter/implquickfilter/handler.go @@ -1,37 +1,33 @@ -package quickfilter +package implquickfilter import ( "encoding/json" + "net/http" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/gorilla/mux" - "net/http" ) -type API interface { - GetQuickFilters(http.ResponseWriter, *http.Request) - UpdateQuickFilters(http.ResponseWriter, *http.Request) - GetSignalFilters(http.ResponseWriter, *http.Request) +type handler struct { + module quickfilter.Module } -type quickFiltersAPI struct { - usecase Usecase +func NewHandler(module quickfilter.Module) quickfilter.Handler { + return &handler{module: module} } -func NewAPI(usecase Usecase) API { - return &quickFiltersAPI{usecase: usecase} -} - -func (q *quickFiltersAPI) GetQuickFilters(rw http.ResponseWriter, r *http.Request) { +func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) { claims, err := authtypes.ClaimsFromContext(r.Context()) if err != nil { render.Error(rw, err) return } - filters, err := q.usecase.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID)) + filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID)) if err != nil { render.Error(rw, err) return @@ -40,7 +36,7 @@ func (q *quickFiltersAPI) GetQuickFilters(rw http.ResponseWriter, r *http.Reques render.Success(rw, http.StatusOK, filters) } -func (q *quickFiltersAPI) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) { +func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) { claims, err := authtypes.ClaimsFromContext(r.Context()) if err != nil { render.Error(rw, err) @@ -54,7 +50,7 @@ func (q *quickFiltersAPI) UpdateQuickFilters(rw http.ResponseWriter, r *http.Req return } - err = q.usecase.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters) + err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters) if err != nil { render.Error(rw, err) return @@ -63,7 +59,7 @@ func (q *quickFiltersAPI) UpdateQuickFilters(rw http.ResponseWriter, r *http.Req render.Success(rw, http.StatusNoContent, nil) } -func (q *quickFiltersAPI) GetSignalFilters(rw http.ResponseWriter, r *http.Request) { +func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request) { claims, err := authtypes.ClaimsFromContext(r.Context()) if err != nil { render.Error(rw, err) @@ -77,7 +73,7 @@ func (q *quickFiltersAPI) GetSignalFilters(rw http.ResponseWriter, r *http.Reque return } - filters, err := q.usecase.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal) + filters, err := handler.module.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal) if err != nil { render.Error(rw, err) return diff --git a/pkg/modules/quickfilter/core/core.go b/pkg/modules/quickfilter/implquickfilter/module.go similarity index 70% rename from pkg/modules/quickfilter/core/core.go rename to pkg/modules/quickfilter/implquickfilter/module.go index 261ea0eac2a9..b1ebc9117f97 100644 --- a/pkg/modules/quickfilter/core/core.go +++ b/pkg/modules/quickfilter/implquickfilter/module.go @@ -1,9 +1,9 @@ -package core +package implquickfilter import ( "context" "encoding/json" - "fmt" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/modules/quickfilter" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" @@ -11,18 +11,17 @@ import ( "github.com/SigNoz/signoz/pkg/valuer" ) -type usecase struct { +type module struct { store quickfiltertypes.QuickFilterStore } -// NewQuickFilters creates a new quick filters usecase -func NewQuickFilters(store quickfiltertypes.QuickFilterStore) quickfilter.Usecase { - return &usecase{store: store} +func NewModule(store quickfiltertypes.QuickFilterStore) quickfilter.Module { + return &module{store: store} } // GetQuickFilters returns all quick filters for an organization -func (u *usecase) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) { - storedFilters, err := u.store.Get(ctx, orgID) +func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) { + storedFilters, err := module.store.Get(ctx, orgID) if err != nil { return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters") } @@ -40,8 +39,8 @@ func (u *usecase) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*qu } // GetSignalFilters returns quick filters for a specific signal in an organization -func (u *usecase) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) { - storedFilter, err := u.store.GetBySignal(ctx, orgID, signal.StringValue()) +func (m *module) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) { + storedFilter, err := m.store.GetBySignal(ctx, orgID, signal.StringValue()) if err != nil { return nil, err } @@ -64,7 +63,7 @@ func (u *usecase) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signa } // UpdateQuickFilters updates quick filters for a specific signal in an organization -func (u *usecase) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error { +func (module *module) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error { // Validate each filter for _, filter := range filters { if err := filter.Validate(); err != nil { @@ -79,7 +78,7 @@ func (u *usecase) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, sig } // Check if filter exists - existingFilter, err := u.store.GetBySignal(ctx, orgID, signal.StringValue()) + existingFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue()) if err != nil { return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error checking existing filters") } @@ -100,17 +99,18 @@ func (u *usecase) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, sig } // Persist filter - if err := u.store.Upsert(ctx, filter); err != nil { - return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, fmt.Sprintf("error upserting filter for signal: %s", signal.StringValue())) + if err := module.store.Upsert(ctx, filter); err != nil { + return err } return nil } -func (u *usecase) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error { +func (module *module) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error { storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(orgID) if err != nil { - return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error creating default quick filters") + return err } - return u.store.Create(ctx, storableQuickFilters) + + return module.store.Create(ctx, storableQuickFilters) } diff --git a/pkg/modules/quickfilter/core/store.go b/pkg/modules/quickfilter/implquickfilter/store.go similarity index 98% rename from pkg/modules/quickfilter/core/store.go rename to pkg/modules/quickfilter/implquickfilter/store.go index 5d829e5da8b2..05f7e55cf10e 100644 --- a/pkg/modules/quickfilter/core/store.go +++ b/pkg/modules/quickfilter/implquickfilter/store.go @@ -1,8 +1,9 @@ -package core +package implquickfilter import ( "context" "database/sql" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" diff --git a/pkg/modules/quickfilter/usecase.go b/pkg/modules/quickfilter/quickfilter.go similarity index 73% rename from pkg/modules/quickfilter/usecase.go rename to pkg/modules/quickfilter/quickfilter.go index 19a1b622b0be..6528b966fc27 100644 --- a/pkg/modules/quickfilter/usecase.go +++ b/pkg/modules/quickfilter/quickfilter.go @@ -2,14 +2,22 @@ package quickfilter import ( "context" + "net/http" + v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" "github.com/SigNoz/signoz/pkg/valuer" ) -type Usecase interface { +type Module interface { GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error } + +type Handler interface { + GetQuickFilters(http.ResponseWriter, *http.Request) + UpdateQuickFilters(http.ResponseWriter, *http.Request) + GetSignalFilters(http.ResponseWriter, *http.Request) +} diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 4caad03633d2..f116eb799c2b 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -24,7 +24,6 @@ import ( "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/licensing" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" "github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer" @@ -142,10 +141,6 @@ type APIHandler struct { FieldsAPI *fields.API Signoz *signoz.SigNoz - - QuickFilters quickfilter.API - - QuickFilterModule quickfilter.Usecase } type APIHandlerOpts struct { @@ -182,10 +177,6 @@ type APIHandlerOpts struct { FieldsAPI *fields.API Signoz *signoz.SigNoz - - QuickFilters quickfilter.API - - QuickFilterModule quickfilter.Usecase } // NewAPIHandler returns an APIHandler @@ -248,8 +239,6 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { LicensingAPI: opts.LicensingAPI, Signoz: opts.Signoz, FieldsAPI: opts.FieldsAPI, - QuickFilters: opts.QuickFilters, - QuickFilterModule: opts.QuickFilterModule, } logsQueryBuilder := logsv4.PrepareLogsQuery @@ -576,9 +565,9 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/org/preferences/{preferenceId}", am.AdminAccess(aH.Signoz.Handlers.Preference.UpdateOrg)).Methods(http.MethodPut) // Quick Filters - router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.QuickFilters.GetQuickFilters)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.QuickFilters.GetSignalFilters)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.QuickFilters.UpdateQuickFilters)).Methods(http.MethodPut) + router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut) // === Authentication APIs === router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.Signoz.Handlers.User.CreateInvite)).Methods(http.MethodPost) @@ -2029,7 +2018,7 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) { return } - _, apiErr := auth.Register(context.Background(), &req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization, aH.Signoz.Modules.User, aH.QuickFilterModule) + _, apiErr := auth.Register(context.Background(), &req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization, aH.Signoz.Modules.User, aH.Signoz.Modules.QuickFilter) if apiErr != nil { RespondError(w, apiErr, nil) return diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index 08a26c87991e..fbd35320c02c 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -15,8 +15,6 @@ import ( "github.com/SigNoz/signoz/pkg/apis/fields" "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/licensing/nooplicensing" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/query-service/agentConf" "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader" @@ -138,8 +136,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { telemetry.GetInstance().SetUserCountCallback(telemetry.GetUserCount) telemetry.GetInstance().SetDashboardsInfoCallback(telemetry.GetDashboardsInfo) - quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(serverOptions.SigNoz.SQLStore)) - quickFilter := quickfilter.NewAPI(quickfiltermodule) apiHandler, err := NewAPIHandler(APIHandlerOpts{ Reader: reader, PreferSpanMetrics: serverOptions.PreferSpanMetrics, @@ -153,8 +149,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { LicensingAPI: nooplicensing.NewLicenseAPI(), FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore), Signoz: serverOptions.SigNoz, - QuickFilters: quickFilter, - QuickFilterModule: quickfiltermodule, }) if err != nil { return nil, err diff --git a/pkg/query-service/auth/auth.go b/pkg/query-service/auth/auth.go index e29d02bee6fc..d157083d4e17 100644 --- a/pkg/query-service/auth/auth.go +++ b/pkg/query-service/auth/auth.go @@ -47,7 +47,7 @@ func RegisterOrgAndFirstUser(ctx context.Context, req *types.PostableRegisterOrg } // First user registration -func Register(ctx context.Context, req *types.PostableRegisterOrgAndAdmin, alertmanager alertmanager.Alertmanager, organizationModule organization.Module, userModule user.Module, quickfiltermodule quickfilter.Usecase) (*types.User, *model.ApiError) { +func Register(ctx context.Context, req *types.PostableRegisterOrgAndAdmin, alertmanager alertmanager.Alertmanager, organizationModule organization.Module, userModule user.Module, quickfiltermodule quickfilter.Module) (*types.User, *model.ApiError) { user, err := RegisterOrgAndFirstUser(ctx, req, organizationModule, userModule) if err != nil { return nil, err diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index b0e02b1ec450..92c8d5abf2b2 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -13,8 +13,6 @@ import ( "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/emailing/noopemailing" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/http/middleware" @@ -312,7 +310,6 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(testDB, userModule) - quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, @@ -321,7 +318,6 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { Modules: modules, Handlers: signoz.NewHandlers(modules, userHandler), }, - QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/query-service/tests/integration/logparsingpipeline_test.go b/pkg/query-service/tests/integration/logparsingpipeline_test.go index a28b91fb32f9..6caa38e6fa9a 100644 --- a/pkg/query-service/tests/integration/logparsingpipeline_test.go +++ b/pkg/query-service/tests/integration/logparsingpipeline_test.go @@ -15,8 +15,6 @@ import ( "github.com/SigNoz/signoz/pkg/emailing/noopemailing" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/agentConf" @@ -489,7 +487,6 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(sqlStore, userModule) handlers := signoz.NewHandlers(modules, userHandler) - quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(sqlStore))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ LogsParsingPipelineController: controller, @@ -498,7 +495,6 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli Modules: modules, Handlers: handlers, }, - QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index 76ea8f590f91..934f45ce8ee2 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -11,8 +11,6 @@ import ( "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/emailing/noopemailing" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/http/middleware" @@ -375,7 +373,6 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(testDB, userModule) handlers := signoz.NewHandlers(modules, userHandler) - quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, @@ -385,7 +382,6 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI Modules: modules, Handlers: handlers, }, - QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index 8751ef5c2aa5..d39098d7db74 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -11,8 +11,6 @@ import ( "github.com/SigNoz/signoz/pkg/emailing" "github.com/SigNoz/signoz/pkg/emailing/noopemailing" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" @@ -581,9 +579,6 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration userHandler := impluser.NewHandler(userModule) modules := signoz.NewModules(testDB, userModule) handlers := signoz.NewHandlers(modules, userHandler) - - quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) - apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, IntegrationsController: controller, @@ -594,7 +589,6 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration Modules: modules, Handlers: handlers, }, - QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) diff --git a/pkg/signoz/handler.go b/pkg/signoz/handler.go index 3196806106aa..375ec93afe19 100644 --- a/pkg/signoz/handler.go +++ b/pkg/signoz/handler.go @@ -9,6 +9,8 @@ import ( "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/preference" "github.com/SigNoz/signoz/pkg/modules/preference/implpreference" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + "github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter" "github.com/SigNoz/signoz/pkg/modules/savedview" "github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview" "github.com/SigNoz/signoz/pkg/modules/user" @@ -21,6 +23,7 @@ type Handlers struct { SavedView savedview.Handler Apdex apdex.Handler Dashboard dashboard.Handler + QuickFilter quickfilter.Handler } func NewHandlers(modules Modules, user user.Handler) Handlers { @@ -31,5 +34,6 @@ func NewHandlers(modules Modules, user user.Handler) Handlers { SavedView: implsavedview.NewHandler(modules.SavedView), Apdex: implapdex.NewHandler(modules.Apdex), Dashboard: impldashboard.NewHandler(modules.Dashboard), + QuickFilter: implquickfilter.NewHandler(modules.QuickFilter), } } diff --git a/pkg/signoz/module.go b/pkg/signoz/module.go index 7fa06b54c2b2..399e189ea1fb 100644 --- a/pkg/signoz/module.go +++ b/pkg/signoz/module.go @@ -9,6 +9,8 @@ import ( "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/preference" "github.com/SigNoz/signoz/pkg/modules/preference/implpreference" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + "github.com/SigNoz/signoz/pkg/modules/quickfilter/implquickfilter" "github.com/SigNoz/signoz/pkg/modules/savedview" "github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview" "github.com/SigNoz/signoz/pkg/modules/user" @@ -23,6 +25,7 @@ type Modules struct { SavedView savedview.Module Apdex apdex.Module Dashboard dashboard.Module + QuickFilter quickfilter.Module } func NewModules(sqlstore sqlstore.SQLStore, user user.Module) Modules { @@ -33,5 +36,6 @@ func NewModules(sqlstore sqlstore.SQLStore, user user.Module) Modules { SavedView: implsavedview.NewModule(sqlstore), Apdex: implapdex.NewModule(sqlstore), Dashboard: impldashboard.NewModule(sqlstore), + QuickFilter: implquickfilter.NewModule(implquickfilter.NewStore(sqlstore)), } } diff --git a/pkg/types/quickfiltertypes/filter.go b/pkg/types/quickfiltertypes/filter.go index ecfa9cfdb907..9d00833c7eae 100644 --- a/pkg/types/quickfiltertypes/filter.go +++ b/pkg/types/quickfiltertypes/filter.go @@ -2,12 +2,13 @@ package quickfiltertypes import ( "encoding/json" + "time" + "github.com/SigNoz/signoz/pkg/errors" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" - "time" ) type Signal struct { @@ -48,7 +49,7 @@ func NewSignal(s string) (Signal, error) { case "exceptions": return SignalExceptions, nil default: - return Signal{}, errors.Newf(errors.TypeInternal, errors.CodeInternal, "invalid signal: "+s) + return Signal{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid signal: %s", s) } } From 403630ad31652cf24096fe857d65e8c36682c639 Mon Sep 17 00:00:00 2001 From: Vibhu Pandey Date: Sat, 24 May 2025 23:53:54 +0530 Subject: [PATCH 03/24] feat(signoz): compile time check for dependency injection (#8033) --- pkg/emailing/emailingtest/provider.go | 28 +++++++++++++++++++++ pkg/signoz/handler_test.go | 36 +++++++++++++++++++++++++++ pkg/signoz/module_test.go | 34 +++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 pkg/emailing/emailingtest/provider.go create mode 100644 pkg/signoz/handler_test.go create mode 100644 pkg/signoz/module_test.go diff --git a/pkg/emailing/emailingtest/provider.go b/pkg/emailing/emailingtest/provider.go new file mode 100644 index 000000000000..1db2b8d997b8 --- /dev/null +++ b/pkg/emailing/emailingtest/provider.go @@ -0,0 +1,28 @@ +package emailingtest + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/types/emailtypes" +) + +var _ emailing.Emailing = (*Provider)(nil) + +type Provider struct { + SentEmailCountByTo map[string]int + SentEmailCountByTemplateName map[emailtypes.TemplateName]int +} + +func New() *Provider { + return &Provider{ + SentEmailCountByTo: make(map[string]int), + SentEmailCountByTemplateName: make(map[emailtypes.TemplateName]int), + } +} + +func (provider *Provider) SendHTML(ctx context.Context, to string, subject string, templateName emailtypes.TemplateName, data map[string]any) error { + provider.SentEmailCountByTo[to]++ + provider.SentEmailCountByTemplateName[templateName]++ + return nil +} diff --git a/pkg/signoz/handler_test.go b/pkg/signoz/handler_test.go new file mode 100644 index 000000000000..d28ab3837642 --- /dev/null +++ b/pkg/signoz/handler_test.go @@ -0,0 +1,36 @@ +package signoz + +import ( + "reflect" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/SigNoz/signoz/pkg/emailing/emailingtest" + "github.com/SigNoz/signoz/pkg/factory/factorytest" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/stretchr/testify/assert" +) + +// This is a test to ensure that all fields of the handlers are initialized. +// It also helps us catch these errors at compile time instead of runtime. +func TestNewHandlers(t *testing.T) { + sqlstore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual) + jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour) + emailing := emailingtest.New() + providerSettings := factorytest.NewSettings() + userModule := impluser.NewModule(impluser.NewStore(sqlstore), jwt, emailing, providerSettings) + userHandler := impluser.NewHandler(userModule) + + modules := NewModules(sqlstore, userModule) + handlers := NewHandlers(modules, userHandler) + + reflectVal := reflect.ValueOf(handlers) + for i := 0; i < reflectVal.NumField(); i++ { + f := reflectVal.Field(i) + assert.False(t, f.IsZero(), "%s handler has not been initialized", reflectVal.Type().Field(i).Name) + } +} diff --git a/pkg/signoz/module_test.go b/pkg/signoz/module_test.go new file mode 100644 index 000000000000..566cb41e0e15 --- /dev/null +++ b/pkg/signoz/module_test.go @@ -0,0 +1,34 @@ +package signoz + +import ( + "reflect" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/SigNoz/signoz/pkg/emailing/emailingtest" + "github.com/SigNoz/signoz/pkg/factory/factorytest" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/stretchr/testify/assert" +) + +// This is a test to ensure that all fields of the modules are initialized. +// It also helps us catch these errors at compile time instead of runtime. +func TestNewModules(t *testing.T) { + sqlstore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual) + jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour) + emailing := emailingtest.New() + providerSettings := factorytest.NewSettings() + userModule := impluser.NewModule(impluser.NewStore(sqlstore), jwt, emailing, providerSettings) + + modules := NewModules(sqlstore, userModule) + + reflectVal := reflect.ValueOf(modules) + for i := 0; i < reflectVal.NumField(); i++ { + f := reflectVal.Field(i) + assert.False(t, f.IsZero(), "%s module has not been initialized", reflectVal.Type().Field(i).Name) + } +} From 2ba693f040967a0ba6dc1030c560b182b9720cf3 Mon Sep 17 00:00:00 2001 From: Vibhu Pandey Date: Sun, 25 May 2025 11:40:39 +0530 Subject: [PATCH 04/24] chore(linter): add more linters and deprecate zap (#8034) * chore(linter): add more linters and deprecate zap * chore(linter): add more linters and deprecate zap * chore(linter): add more linters and deprecate zap * chore(linter): add more linters and deprecate zap --- .golangci.yml | 29 ++++++++++++++ ee/http/middleware/api_key.go | 9 +++-- ee/licensing/httplicensing/provider.go | 11 ++---- ee/modules/user/impluser/module.go | 6 --- ee/query-service/app/api/api.go | 2 +- ee/query-service/app/server.go | 20 +++++----- .../legacyalertmanager/provider.go | 2 +- pkg/alertmanager/service.go | 12 +++--- pkg/apis/fields/api.go | 7 ++-- pkg/cache/memorycache/provider.go | 2 +- pkg/cache/rediscache/provider.go | 20 ++++------ pkg/cache/rediscache/provider_test.go | 20 +++++----- pkg/emailing/smtpemailing/provider.go | 2 +- .../templatestore/filetemplatestore/store.go | 6 +-- pkg/http/middleware/analytics.go | 33 ++++------------ pkg/http/middleware/auth.go | 10 +---- pkg/http/middleware/cache_test.go | 3 ++ pkg/http/middleware/logging.go | 38 +++++++++---------- pkg/http/middleware/timeout.go | 15 +++----- pkg/http/middleware/timeout_test.go | 10 +++-- pkg/http/render/render_test.go | 6 +++ pkg/http/server/server.go | 16 ++++---- pkg/query-service/app/server.go | 18 ++++----- .../integration/filter_suggestions_test.go | 2 +- .../signoz_cloud_integrations_test.go | 3 +- .../integration/signoz_integrations_test.go | 3 +- .../rulestore/sqlrulestore/maintenance.go | 4 -- pkg/ruler/rulestore/sqlrulestore/rule.go | 4 -- pkg/smtp/client/smtp.go | 2 +- pkg/sqlmigration/026_update_integrations.go | 2 - pkg/sqlstore/sqlstorehook/logging.go | 6 +-- pkg/telemetrylogs/filter_expr_logs_test.go | 2 +- pkg/telemetrymetadata/metadata.go | 9 +++-- pkg/telemetrymetadata/metadata_test.go | 3 ++ pkg/types/domain.go | 2 - pkg/types/integration.go | 10 ++--- pkg/types/ruletypes/maintenance.go | 25 ------------ pkg/types/ssotypes/saml.go | 3 +- ...tadata_store_stub.go => metadata_store.go} | 2 +- 39 files changed, 168 insertions(+), 211 deletions(-) rename pkg/types/telemetrytypes/telemetrytypestest/{metadata_store_stub.go => metadata_store.go} (99%) diff --git a/.golangci.yml b/.golangci.yml index bd48535d9425..fa16191b1727 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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/" diff --git a/ee/http/middleware/api_key.go b/ee/http/middleware/api_key.go index 96e35619a082..01e1981bd767 100644 --- a/ee/http/middleware/api_key.go +++ b/ee/http/middleware/api_key.go @@ -1,23 +1,24 @@ package middleware import ( + "log/slog" "net/http" "time" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" - "go.uber.org/zap" ) type APIKey struct { store sqlstore.SQLStore uuid *authtypes.UUID headers []string + logger *slog.Logger } -func NewAPIKey(store sqlstore.SQLStore, headers []string) *APIKey { - return &APIKey{store: store, uuid: authtypes.NewUUID(), headers: headers} +func NewAPIKey(store sqlstore.SQLStore, headers []string, logger *slog.Logger) *APIKey { + return &APIKey{store: store, uuid: authtypes.NewUUID(), headers: headers, logger: logger} } func (a *APIKey) Wrap(next http.Handler) http.Handler { @@ -77,7 +78,7 @@ func (a *APIKey) Wrap(next http.Handler) http.Handler { apiKey.LastUsed = time.Now() _, err = a.store.BunDB().NewUpdate().Model(&apiKey).Column("last_used").Where("token = ?", apiKeyToken).Where("revoked = false").Exec(r.Context()) if err != nil { - zap.L().Error("Failed to update APIKey last used in db", zap.Error(err)) + a.logger.ErrorContext(r.Context(), "failed to update last used of api key", "error", err) } }) diff --git a/ee/licensing/httplicensing/provider.go b/ee/licensing/httplicensing/provider.go index 3764edc7e9b9..0c63ff295bdc 100644 --- a/ee/licensing/httplicensing/provider.go +++ b/ee/licensing/httplicensing/provider.go @@ -79,7 +79,6 @@ func (provider *provider) Validate(ctx context.Context) error { } if len(organizations) == 0 { - provider.settings.Logger().DebugContext(ctx, "no organizations found, defaulting to basic plan") err = provider.InitFeatures(ctx, licensetypes.BasicPlan) if err != nil { return err @@ -129,15 +128,14 @@ func (provider *provider) GetActive(ctx context.Context, organizationID valuer.U } func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUID) error { - provider.settings.Logger().DebugContext(ctx, "license validation started for organizationID", "organizationID", organizationID.StringValue()) activeLicense, err := provider.GetActive(ctx, organizationID) if err != nil && !errors.Ast(err, errors.TypeNotFound) { - provider.settings.Logger().ErrorContext(ctx, "license validation failed", "organizationID", organizationID.StringValue()) + 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", "organizationID", organizationID.StringValue()) + 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 @@ -147,10 +145,8 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI data, err := provider.zeus.GetLicense(ctx, activeLicense.Key) if err != nil { - provider.settings.Logger().ErrorContext(ctx, "failed to validate the license with upstream server", "licenseID", activeLicense.Key, "organizationID", organizationID.StringValue()) - 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", "failureThreshold", provider.config.FailureThreshold, "licenseID", activeLicense.ID.StringValue(), "organizationID", organizationID.StringValue()) + 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 @@ -165,7 +161,6 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to create license entity from license data") } - provider.settings.Logger().DebugContext(ctx, "license validation completed successfully", "licenseID", activeLicense.ID, "organizationID", organizationID.StringValue()) updatedStorableLicense := licensetypes.NewStorableLicenseFromLicense(activeLicense) err = provider.store.Update(ctx, organizationID, updatedStorableLicense) if err != nil { diff --git a/ee/modules/user/impluser/module.go b/ee/modules/user/impluser/module.go index 92200f2aa310..c62526a4b2ec 100644 --- a/ee/modules/user/impluser/module.go +++ b/ee/modules/user/impluser/module.go @@ -15,7 +15,6 @@ import ( "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 @@ -67,7 +66,6 @@ func (m *Module) createUserForSAMLRequest(ctx context.Context, email string) (*t 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{} @@ -76,7 +74,6 @@ func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email stri 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 { @@ -85,7 +82,6 @@ func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email stri 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 } @@ -164,7 +160,6 @@ func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl stri // 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") } @@ -197,7 +192,6 @@ func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl stri // 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") } diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 3d5729156118..2e512823f5d8 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -67,7 +67,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, FluxInterval: opts.FluxInterval, AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager), LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing), - FieldsAPI: fields.NewAPI(signoz.TelemetryStore), + FieldsAPI: fields.NewAPI(signoz.TelemetryStore, signoz.Instrumentation.Logger()), Signoz: signoz, }) diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 9411c05e90a7..f694297b8c9e 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -247,15 +247,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(eemiddleware.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) @@ -279,15 +279,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(eemiddleware.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) diff --git a/pkg/alertmanager/legacyalertmanager/provider.go b/pkg/alertmanager/legacyalertmanager/provider.go index f61d8c372028..b8fbba68e343 100644 --- a/pkg/alertmanager/legacyalertmanager/provider.go +++ b/pkg/alertmanager/legacyalertmanager/provider.go @@ -168,7 +168,7 @@ func (provider *provider) putAlerts(ctx context.Context, orgID string, alerts al receivers := cfg.ReceiverNamesFromRuleID(ruleID) if len(receivers) == 0 { - provider.settings.Logger().WarnContext(ctx, "cannot find receivers for alert, skipping sending alert to alertmanager", "ruleID", ruleID, "alert", alert) + provider.settings.Logger().WarnContext(ctx, "cannot find receivers for alert, skipping sending alert to alertmanager", "rule_id", ruleID, "alert", alert) continue } diff --git a/pkg/alertmanager/service.go b/pkg/alertmanager/service.go index 8106b678d384..d8fdd74b2897 100644 --- a/pkg/alertmanager/service.go +++ b/pkg/alertmanager/service.go @@ -53,7 +53,7 @@ func (service *Service) SyncServers(ctx context.Context) error { for _, orgID := range orgIDs { config, err := service.getConfig(ctx, orgID) if err != nil { - service.settings.Logger().Error("failed to get alertmanager config for org", "orgID", orgID, "error", err) + service.settings.Logger().ErrorContext(ctx, "failed to get alertmanager config for org", "org_id", orgID, "error", err) continue } @@ -61,7 +61,7 @@ func (service *Service) SyncServers(ctx context.Context) error { if _, ok := service.servers[orgID]; !ok { server, err := service.newServer(ctx, orgID) if err != nil { - service.settings.Logger().Error("failed to create alertmanager server", "orgID", orgID, "error", err) + service.settings.Logger().ErrorContext(ctx, "failed to create alertmanager server", "org_id", orgID, "error", err) continue } @@ -69,13 +69,13 @@ func (service *Service) SyncServers(ctx context.Context) error { } if service.servers[orgID].Hash() == config.StoreableConfig().Hash { - service.settings.Logger().Debug("skipping alertmanager sync for org", "orgID", orgID, "hash", config.StoreableConfig().Hash) + service.settings.Logger().DebugContext(ctx, "skipping alertmanager sync for org", "org_id", orgID, "hash", config.StoreableConfig().Hash) continue } err = service.servers[orgID].SetConfig(ctx, config) if err != nil { - service.settings.Logger().Error("failed to set config for alertmanager server", "orgID", orgID, "error", err) + service.settings.Logger().ErrorContext(ctx, "failed to set config for alertmanager server", "org_id", orgID, "error", err) continue } } @@ -142,7 +142,7 @@ func (service *Service) Stop(ctx context.Context) error { for _, server := range service.servers { if err := server.Stop(ctx); err != nil { errs = append(errs, err) - service.settings.Logger().Error("failed to stop alertmanager server", "error", err) + service.settings.Logger().ErrorContext(ctx, "failed to stop alertmanager server", "error", err) } } @@ -167,7 +167,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana } if beforeCompareAndSelectHash == config.StoreableConfig().Hash { - service.settings.Logger().Debug("skipping config store update for org", "orgID", orgID, "hash", config.StoreableConfig().Hash) + service.settings.Logger().DebugContext(ctx, "skipping config store update for org", "org_id", orgID, "hash", config.StoreableConfig().Hash) return server, nil } diff --git a/pkg/apis/fields/api.go b/pkg/apis/fields/api.go index e32f75ec42d8..2341bc6c61d3 100644 --- a/pkg/apis/fields/api.go +++ b/pkg/apis/fields/api.go @@ -3,6 +3,7 @@ package fields import ( "bytes" "io" + "log/slog" "net/http" "github.com/SigNoz/signoz/pkg/http/render" @@ -12,7 +13,6 @@ import ( "github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/telemetrytraces" "github.com/SigNoz/signoz/pkg/types/telemetrytypes" - "go.uber.org/zap" ) type API struct { @@ -20,9 +20,9 @@ type API struct { telemetryMetadataStore telemetrytypes.MetadataStore } -func NewAPI(telemetryStore telemetrystore.TelemetryStore) *API { - +func NewAPI(telemetryStore telemetrystore.TelemetryStore, logger *slog.Logger) *API { telemetryMetadataStore := telemetrymetadata.NewTelemetryMetaStore( + logger, telemetryStore, telemetrytraces.DBName, telemetrytraces.TagAttributesV2TableName, @@ -99,7 +99,6 @@ func (api *API) GetFieldsValues(w http.ResponseWriter, r *http.Request) { relatedValues, err := api.telemetryMetadataStore.GetRelatedValues(ctx, fieldValueSelector) if err != nil { // we don't want to return error if we fail to get related values for some reason - zap.L().Error("failed to get related values", zap.Error(err)) relatedValues = []string{} } diff --git a/pkg/cache/memorycache/provider.go b/pkg/cache/memorycache/provider.go index 2ff40e826630..7a45bcfa03f8 100644 --- a/pkg/cache/memorycache/provider.go +++ b/pkg/cache/memorycache/provider.go @@ -37,7 +37,7 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s } if ttl == 0 { - provider.settings.Logger().WarnContext(ctx, "zero value for TTL found. defaulting to the base TTL", "cacheKey", cacheKey, "defaultTTL", provider.config.Memory.TTL) + provider.settings.Logger().WarnContext(ctx, "zero value for TTL found. defaulting to the base TTL", "cache_key", cacheKey, "default_ttl", provider.config.Memory.TTL) } provider.cc.Set(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), data, ttl) return nil diff --git a/pkg/cache/rediscache/provider.go b/pkg/cache/rediscache/provider.go index 0a43b4bb191b..8106628500a4 100644 --- a/pkg/cache/rediscache/provider.go +++ b/pkg/cache/rediscache/provider.go @@ -14,34 +14,30 @@ import ( "github.com/SigNoz/signoz/pkg/types/cachetypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/go-redis/redis/v8" - "go.uber.org/zap" ) type provider struct { - client *redis.Client + client *redis.Client + settings factory.ScopedProviderSettings } func NewFactory() factory.ProviderFactory[cache.Cache, cache.Config] { return factory.NewProviderFactory(factory.MustNewName("redis"), New) } -func New(ctx context.Context, settings factory.ProviderSettings, config cache.Config) (cache.Cache, error) { - provider := new(provider) - provider.client = redis.NewClient(&redis.Options{ +func New(ctx context.Context, providerSettings factory.ProviderSettings, config cache.Config) (cache.Cache, error) { + settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/cache/rediscache") + client := redis.NewClient(&redis.Options{ Addr: strings.Join([]string{config.Redis.Host, fmt.Sprint(config.Redis.Port)}, ":"), Password: config.Redis.Password, DB: config.Redis.DB, }) - if err := provider.client.Ping(ctx).Err(); err != nil { + if err := client.Ping(ctx).Err(); err != nil { return nil, err } - return provider, nil -} - -func WithClient(client *redis.Client) *provider { - return &provider{client: client} + return &provider{client: client, settings: settings}, nil } func (c *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey string, data cachetypes.Cacheable, ttl time.Duration) error { @@ -70,6 +66,6 @@ func (c *provider) DeleteMany(ctx context.Context, orgID valuer.UUID, cacheKeys } if err := c.client.Del(ctx, updatedCacheKeys...).Err(); err != nil { - zap.L().Error("error deleting cache keys", zap.Strings("cacheKeys", cacheKeys), zap.Error(err)) + c.settings.Logger().ErrorContext(ctx, "error deleting cache keys", "cache_keys", cacheKeys, "error", err) } } diff --git a/pkg/cache/rediscache/provider_test.go b/pkg/cache/rediscache/provider_test.go index 223a41aeb8c1..e9a6ff2a3a38 100644 --- a/pkg/cache/rediscache/provider_test.go +++ b/pkg/cache/rediscache/provider_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/factory/factorytest" "github.com/SigNoz/signoz/pkg/valuer" "github.com/go-redis/redismock/v8" "github.com/stretchr/testify/assert" @@ -28,7 +30,7 @@ func (ce *CacheableEntity) UnmarshalBinary(data []byte) error { func TestSet(t *testing.T) { db, mock := redismock.NewClientMock() - cache := WithClient(db) + cache := &provider{client: db, settings: factory.NewScopedProviderSettings(factorytest.NewSettings(), "github.com/SigNoz/signoz/pkg/cache/rediscache")} storeCacheableEntity := &CacheableEntity{ Key: "some-random-key", Value: 1, @@ -46,7 +48,7 @@ func TestSet(t *testing.T) { func TestGet(t *testing.T) { db, mock := redismock.NewClientMock() - cache := WithClient(db) + cache := &provider{client: db, settings: factory.NewScopedProviderSettings(factorytest.NewSettings(), "github.com/SigNoz/signoz/pkg/cache/rediscache")} storeCacheableEntity := &CacheableEntity{ Key: "some-random-key", Value: 1, @@ -75,7 +77,7 @@ func TestGet(t *testing.T) { func TestDelete(t *testing.T) { db, mock := redismock.NewClientMock() - c := WithClient(db) + cache := &provider{client: db, settings: factory.NewScopedProviderSettings(factorytest.NewSettings(), "github.com/SigNoz/signoz/pkg/cache/rediscache")} storeCacheableEntity := &CacheableEntity{ Key: "some-random-key", Value: 1, @@ -84,10 +86,10 @@ func TestDelete(t *testing.T) { orgID := valuer.GenerateUUID() mock.ExpectSet(strings.Join([]string{orgID.StringValue(), "key"}, "::"), storeCacheableEntity, 10*time.Second).RedisNil() - _ = c.Set(context.Background(), orgID, "key", storeCacheableEntity, 10*time.Second) + _ = cache.Set(context.Background(), orgID, "key", storeCacheableEntity, 10*time.Second) mock.ExpectDel(strings.Join([]string{orgID.StringValue(), "key"}, "::")).RedisNil() - c.Delete(context.Background(), orgID, "key") + cache.Delete(context.Background(), orgID, "key") if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("there were unfulfilled expectations: %s", err) @@ -96,7 +98,7 @@ func TestDelete(t *testing.T) { func TestDeleteMany(t *testing.T) { db, mock := redismock.NewClientMock() - c := WithClient(db) + cache := &provider{client: db, settings: factory.NewScopedProviderSettings(factorytest.NewSettings(), "github.com/SigNoz/signoz/pkg/cache/rediscache")} storeCacheableEntity := &CacheableEntity{ Key: "some-random-key", Value: 1, @@ -105,13 +107,13 @@ func TestDeleteMany(t *testing.T) { orgID := valuer.GenerateUUID() mock.ExpectSet(strings.Join([]string{orgID.StringValue(), "key"}, "::"), storeCacheableEntity, 10*time.Second).RedisNil() - _ = c.Set(context.Background(), orgID, "key", storeCacheableEntity, 10*time.Second) + _ = cache.Set(context.Background(), orgID, "key", storeCacheableEntity, 10*time.Second) mock.ExpectSet(strings.Join([]string{orgID.StringValue(), "key2"}, "::"), storeCacheableEntity, 10*time.Second).RedisNil() - _ = c.Set(context.Background(), orgID, "key2", storeCacheableEntity, 10*time.Second) + _ = cache.Set(context.Background(), orgID, "key2", storeCacheableEntity, 10*time.Second) mock.ExpectDel(strings.Join([]string{orgID.StringValue(), "key"}, "::"), strings.Join([]string{orgID.StringValue(), "key2"}, "::")).RedisNil() - c.DeleteMany(context.Background(), orgID, []string{"key", "key2"}) + cache.DeleteMany(context.Background(), orgID, []string{"key", "key2"}) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("there were unfulfilled expectations: %s", err) diff --git a/pkg/emailing/smtpemailing/provider.go b/pkg/emailing/smtpemailing/provider.go index 66b9489a11fa..07eb0c610099 100644 --- a/pkg/emailing/smtpemailing/provider.go +++ b/pkg/emailing/smtpemailing/provider.go @@ -25,7 +25,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/emailing/smtpemailing") // Try to create a template store. If it fails, use an empty store. - store, err := filetemplatestore.NewStore(config.Templates.Directory, emailtypes.Templates, settings.Logger()) + store, err := filetemplatestore.NewStore(ctx, config.Templates.Directory, emailtypes.Templates, settings.Logger()) if err != nil { settings.Logger().ErrorContext(ctx, "failed to create template store, using empty store", "error", err) store = filetemplatestore.NewEmptyStore() diff --git a/pkg/emailing/templatestore/filetemplatestore/store.go b/pkg/emailing/templatestore/filetemplatestore/store.go index 5af518d0f83d..d79a2492470c 100644 --- a/pkg/emailing/templatestore/filetemplatestore/store.go +++ b/pkg/emailing/templatestore/filetemplatestore/store.go @@ -21,7 +21,7 @@ type store struct { fs map[emailtypes.TemplateName]*template.Template } -func NewStore(baseDir string, templates []emailtypes.TemplateName, logger *slog.Logger) (emailtypes.TemplateStore, error) { +func NewStore(ctx context.Context, baseDir string, templates []emailtypes.TemplateName, logger *slog.Logger) (emailtypes.TemplateStore, error) { fs := make(map[emailtypes.TemplateName]*template.Template) fis, err := os.ReadDir(filepath.Clean(baseDir)) if err != nil { @@ -45,7 +45,7 @@ func NewStore(baseDir string, templates []emailtypes.TemplateName, logger *slog. t, err := parseTemplateFile(filepath.Join(baseDir, fi.Name()), templateName) if err != nil { - logger.Error("failed to parse template file", "template", templateName, "path", filepath.Join(baseDir, fi.Name()), "error", err) + logger.ErrorContext(ctx, "failed to parse template file", "template", templateName, "path", filepath.Join(baseDir, fi.Name()), "error", err) continue } @@ -54,7 +54,7 @@ func NewStore(baseDir string, templates []emailtypes.TemplateName, logger *slog. } if err := checkMissingTemplates(templates, foundTemplates); err != nil { - logger.Error("some templates are missing", "error", err) + logger.ErrorContext(ctx, "some templates are missing", "error", err) } return &store{fs: fs}, nil diff --git a/pkg/http/middleware/analytics.go b/pkg/http/middleware/analytics.go index 0930935bbca1..db8d871b12a7 100644 --- a/pkg/http/middleware/analytics.go +++ b/pkg/http/middleware/analytics.go @@ -11,19 +11,12 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/telemetry" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/gorilla/mux" - "go.uber.org/zap" ) -type Analytics struct { - logger *zap.Logger -} +type Analytics struct{} -func NewAnalytics(logger *zap.Logger) *Analytics { - if logger == nil { - panic("cannot build analytics middleware, logger is empty") - } - - return &Analytics{logger: logger} +func NewAnalytics() *Analytics { + return &Analytics{} } func (a *Analytics) Wrap(next http.Handler) http.Handler { @@ -94,22 +87,10 @@ func (a *Analytics) extractQueryRangeData(path string, r *http.Request) (map[str referrer := r.Header.Get("Referer") - dashboardMatched, err := regexp.MatchString(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`, referrer) - if err != nil { - a.logger.Error("error while matching the referrer", zap.Error(err)) - } - alertMatched, err := regexp.MatchString(`/alerts/(new|edit)(?:\?.*)?$`, referrer) - if err != nil { - a.logger.Error("error while matching the alert: ", zap.Error(err)) - } - logsExplorerMatched, err := regexp.MatchString(`/logs/logs-explorer(?:\?.*)?$`, referrer) - if err != nil { - a.logger.Error("error while matching the logs explorer: ", zap.Error(err)) - } - traceExplorerMatched, err := regexp.MatchString(`/traces-explorer(?:\?.*)?$`, referrer) - if err != nil { - a.logger.Error("error while matching the trace explorer: ", zap.Error(err)) - } + dashboardMatched, _ := regexp.MatchString(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`, referrer) + alertMatched, _ := regexp.MatchString(`/alerts/(new|edit)(?:\?.*)?$`, referrer) + logsExplorerMatched, _ := regexp.MatchString(`/logs/logs-explorer(?:\?.*)?$`, referrer) + traceExplorerMatched, _ := regexp.MatchString(`/traces-explorer(?:\?.*)?$`, referrer) queryInfoResult := telemetry.GetInstance().CheckQueryInfo(postData) diff --git a/pkg/http/middleware/auth.go b/pkg/http/middleware/auth.go index 719d66bdf1b0..491ccb93f12c 100644 --- a/pkg/http/middleware/auth.go +++ b/pkg/http/middleware/auth.go @@ -4,21 +4,15 @@ import ( "net/http" "github.com/SigNoz/signoz/pkg/types/authtypes" - "go.uber.org/zap" ) type Auth struct { - logger *zap.Logger jwt *authtypes.JWT headers []string } -func NewAuth(logger *zap.Logger, jwt *authtypes.JWT, headers []string) *Auth { - if logger == nil { - panic("cannot build auth middleware, logger is empty") - } - - return &Auth{logger: logger, jwt: jwt, headers: headers} +func NewAuth(jwt *authtypes.JWT, headers []string) *Auth { + return &Auth{jwt: jwt, headers: headers} } func (a *Auth) Wrap(next http.Handler) http.Handler { diff --git a/pkg/http/middleware/cache_test.go b/pkg/http/middleware/cache_test.go index 80c55a767dcd..3adeee06bb96 100644 --- a/pkg/http/middleware/cache_test.go +++ b/pkg/http/middleware/cache_test.go @@ -47,6 +47,9 @@ func TestCache(t *testing.T) { res, err := http.DefaultClient.Do(req) require.NoError(t, err) + defer func() { + require.NoError(t, res.Body.Close()) + }() actual := res.Header.Get("Cache-control") require.NoError(t, err) diff --git a/pkg/http/middleware/logging.go b/pkg/http/middleware/logging.go index 61dbbab67d76..ba3d805758b3 100644 --- a/pkg/http/middleware/logging.go +++ b/pkg/http/middleware/logging.go @@ -3,6 +3,7 @@ package middleware import ( "bytes" "context" + "log/slog" "net" "net/http" "net/url" @@ -13,7 +14,6 @@ import ( "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/gorilla/mux" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" - "go.uber.org/zap" ) const ( @@ -21,22 +21,18 @@ const ( ) type Logging struct { - logger *zap.Logger + logger *slog.Logger excludedRoutes map[string]struct{} } -func NewLogging(logger *zap.Logger, excludedRoutes []string) *Logging { - if logger == nil { - panic("cannot build logging, logger is empty") - } - +func NewLogging(logger *slog.Logger, excludedRoutes []string) *Logging { excludedRoutesMap := make(map[string]struct{}) for _, route := range excludedRoutes { excludedRoutesMap[route] = struct{}{} } return &Logging{ - logger: logger.Named(pkgname), + logger: logger.With("pkg", pkgname), excludedRoutes: excludedRoutesMap, } } @@ -50,13 +46,13 @@ func (middleware *Logging) Wrap(next http.Handler) http.Handler { path = req.URL.Path } - fields := []zap.Field{ - zap.String(string(semconv.ClientAddressKey), req.RemoteAddr), - zap.String(string(semconv.UserAgentOriginalKey), req.UserAgent()), - zap.String(string(semconv.ServerAddressKey), host), - zap.String(string(semconv.ServerPortKey), port), - zap.Int64(string(semconv.HTTPRequestSizeKey), req.ContentLength), - zap.String(string(semconv.HTTPRouteKey), path), + fields := []any{ + string(semconv.ClientAddressKey), req.RemoteAddr, + string(semconv.UserAgentOriginalKey), req.UserAgent(), + string(semconv.ServerAddressKey), host, + string(semconv.ServerPortKey), port, + string(semconv.HTTPRequestSizeKey), req.ContentLength, + string(semconv.HTTPRouteKey), path, } logCommentKVs := middleware.getLogCommentKVs(req) @@ -73,19 +69,19 @@ func (middleware *Logging) Wrap(next http.Handler) http.Handler { statusCode, err := writer.StatusCode(), writer.WriteError() fields = append(fields, - zap.Int(string(semconv.HTTPResponseStatusCodeKey), statusCode), - zap.Duration(string(semconv.HTTPServerRequestDurationName), time.Since(start)), + string(semconv.HTTPResponseStatusCodeKey), statusCode, + string(semconv.HTTPServerRequestDurationName), time.Since(start), ) if err != nil { - fields = append(fields, zap.Error(err)) - middleware.logger.Error(logMessage, fields...) + fields = append(fields, "error", err) + middleware.logger.ErrorContext(req.Context(), logMessage, fields...) } else { // when the status code is 400 or >=500, and the response body is not empty. if badResponseBuffer.Len() != 0 { - fields = append(fields, zap.String("response.body", badResponseBuffer.String())) + fields = append(fields, "response.body", badResponseBuffer.String()) } - middleware.logger.Info(logMessage, fields...) + middleware.logger.InfoContext(req.Context(), logMessage, fields...) } }) } diff --git a/pkg/http/middleware/timeout.go b/pkg/http/middleware/timeout.go index 84ca3d27b652..9909336be78b 100644 --- a/pkg/http/middleware/timeout.go +++ b/pkg/http/middleware/timeout.go @@ -2,11 +2,10 @@ package middleware import ( "context" + "log/slog" "net/http" "strings" "time" - - "go.uber.org/zap" ) const ( @@ -14,7 +13,7 @@ const ( ) type Timeout struct { - logger *zap.Logger + logger *slog.Logger excluded map[string]struct{} // The default timeout defaultTimeout time.Duration @@ -22,11 +21,7 @@ type Timeout struct { maxTimeout time.Duration } -func NewTimeout(logger *zap.Logger, excludedRoutes []string, defaultTimeout time.Duration, maxTimeout time.Duration) *Timeout { - if logger == nil { - panic("cannot build timeout, logger is empty") - } - +func NewTimeout(logger *slog.Logger, excludedRoutes []string, defaultTimeout time.Duration, maxTimeout time.Duration) *Timeout { excluded := make(map[string]struct{}, len(excludedRoutes)) for _, route := range excludedRoutes { excluded[route] = struct{}{} @@ -41,7 +36,7 @@ func NewTimeout(logger *zap.Logger, excludedRoutes []string, defaultTimeout time } return &Timeout{ - logger: logger.Named(pkgname), + logger: logger.With("pkg", pkgname), excluded: excluded, defaultTimeout: defaultTimeout, maxTimeout: maxTimeout, @@ -56,7 +51,7 @@ func (middleware *Timeout) Wrap(next http.Handler) http.Handler { if incoming != "" { parsed, err := time.ParseDuration(strings.TrimSpace(incoming) + "s") if err != nil { - middleware.logger.Warn("cannot parse timeout in header, using default timeout", zap.String("timeout", incoming), zap.Error(err), zap.Any("context", req.Context())) + middleware.logger.WarnContext(req.Context(), "cannot parse timeout in header, using default timeout", "timeout", incoming, "error", err) } else { if parsed > middleware.maxTimeout { actual = middleware.maxTimeout diff --git a/pkg/http/middleware/timeout_test.go b/pkg/http/middleware/timeout_test.go index e18291786dd6..56eb687b15f3 100644 --- a/pkg/http/middleware/timeout_test.go +++ b/pkg/http/middleware/timeout_test.go @@ -1,13 +1,14 @@ package middleware import ( + "io" + "log/slog" "net" "net/http" "testing" "time" "github.com/stretchr/testify/require" - "go.uber.org/zap" ) func TestTimeout(t *testing.T) { @@ -16,7 +17,7 @@ func TestTimeout(t *testing.T) { writeTimeout := 6 * time.Second defaultTimeout := 2 * time.Second maxTimeout := 4 * time.Second - m := NewTimeout(zap.NewNop(), []string{"/excluded"}, defaultTimeout, maxTimeout) + m := NewTimeout(slog.New(slog.NewTextHandler(io.Discard, nil)), []string{"/excluded"}, defaultTimeout, maxTimeout) listener, err := net.Listen("tcp", "localhost:0") require.NoError(t, err) @@ -70,8 +71,11 @@ func TestTimeout(t *testing.T) { require.NoError(t, err) req.Header.Add(headerName, tc.header) - _, err = http.DefaultClient.Do(req) + res, err := http.DefaultClient.Do(req) require.NoError(t, err) + defer func() { + require.NoError(t, res.Body.Close()) + }() // confirm that we waited at least till the "wait" time require.GreaterOrEqual(t, time.Since(start), tc.wait) diff --git a/pkg/http/render/render_test.go b/pkg/http/render/render_test.go index 5b6b28149ce1..42f4565de7ac 100644 --- a/pkg/http/render/render_test.go +++ b/pkg/http/render/render_test.go @@ -47,6 +47,9 @@ func TestSuccess(t *testing.T) { res, err := http.DefaultClient.Do(req) require.NoError(t, err) + defer func() { + require.NoError(t, res.Body.Close()) + }() actual, err := io.ReadAll(res.Body) require.NoError(t, err) @@ -104,6 +107,9 @@ func TestError(t *testing.T) { res, err := http.DefaultClient.Do(req) require.NoError(t, err) + defer func() { + require.NoError(t, res.Body.Close()) + }() actual, err := io.ReadAll(res.Body) require.NoError(t, err) diff --git a/pkg/http/server/server.go b/pkg/http/server/server.go index 449eff28f82a..6d1c5c71a63b 100644 --- a/pkg/http/server/server.go +++ b/pkg/http/server/server.go @@ -3,23 +3,23 @@ package server import ( "context" "fmt" + "log/slog" "net/http" "time" "github.com/SigNoz/signoz/pkg/factory" - "go.uber.org/zap" ) var _ factory.Service = (*Server)(nil) type Server struct { srv *http.Server - logger *zap.Logger + logger *slog.Logger handler http.Handler cfg Config } -func New(logger *zap.Logger, cfg Config, handler http.Handler) (*Server, error) { +func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error) { if handler == nil { return nil, fmt.Errorf("cannot build http server, handler is required") } @@ -38,17 +38,17 @@ func New(logger *zap.Logger, cfg Config, handler http.Handler) (*Server, error) return &Server{ srv: srv, - logger: logger.Named("go.signoz.io/pkg/http/server"), + logger: logger.With("pkg", "go.signoz.io/pkg/http/server"), handler: handler, cfg: cfg, }, nil } func (server *Server) Start(ctx context.Context) error { - server.logger.Info("starting http server", zap.String("address", server.srv.Addr)) + server.logger.InfoContext(ctx, "starting http server", "address", server.srv.Addr) if err := server.srv.ListenAndServe(); err != nil { if err != http.ErrServerClosed { - server.logger.Error("failed to start server", zap.Error(err), zap.Any("context", ctx)) + server.logger.ErrorContext(ctx, "failed to start server", "error", err) return err } } @@ -60,10 +60,10 @@ func (server *Server) Stop(ctx context.Context) error { defer cancel() if err := server.srv.Shutdown(ctx); err != nil { - server.logger.Error("failed to stop server", zap.Error(err), zap.Any("context", ctx)) + server.logger.ErrorContext(ctx, "failed to stop server", "error", err) return err } - server.logger.Info("server stopped gracefully", zap.Any("context", ctx)) + server.logger.InfoContext(ctx, "server stopped gracefully") return nil } diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index fbd35320c02c..ea8ee195d696 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -147,7 +147,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { JWT: serverOptions.Jwt, AlertmanagerAPI: alertmanager.NewAPI(serverOptions.SigNoz.Alertmanager), LicensingAPI: nooplicensing.NewLicenseAPI(), - FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore), + FieldsAPI: fields.NewAPI(serverOptions.SigNoz.TelemetryStore, serverOptions.SigNoz.Instrumentation.Logger()), Signoz: serverOptions.SigNoz, }) if err != nil { @@ -212,14 +212,14 @@ func (s *Server) createPrivateServer(api *APIHandler) (*http.Server, error) { r := NewRouter() - r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) - r.Use(middleware.NewTimeout(zap.L(), + r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).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) api.RegisterPrivateRoutes(r) @@ -242,14 +242,14 @@ func (s *Server) createPrivateServer(api *APIHandler) (*http.Server, error) { func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server, error) { r := NewRouter() - r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) - r.Use(middleware.NewTimeout(zap.L(), + r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).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) am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger()) diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index 92c8d5abf2b2..7c1a59bf32be 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -325,7 +325,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { router := app.NewRouter() //add the jwt middleware - router.Use(middleware.NewAuth(zap.L(), jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) + router.Use(middleware.NewAuth(jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) am := middleware.NewAuthZ(instrumentationtest.New().Logger()) apiHandler.RegisterRoutes(router, am) apiHandler.RegisterQueryRangeV3Routes(router, am) diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index 934f45ce8ee2..f8c78f763545 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -28,7 +28,6 @@ import ( "github.com/google/uuid" mockhouse "github.com/srikanthccv/ClickHouse-go-mock" "github.com/stretchr/testify/require" - "go.uber.org/zap" ) func TestAWSIntegrationAccountLifecycle(t *testing.T) { @@ -388,7 +387,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI } router := app.NewRouter() - router.Use(middleware.NewAuth(zap.L(), jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) + router.Use(middleware.NewAuth(jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) am := middleware.NewAuthZ(instrumentationtest.New().Logger()) apiHandler.RegisterRoutes(router, am) apiHandler.RegisterCloudIntegrationsRoutes(router, am) diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index d39098d7db74..78b14cfaeb43 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -30,7 +30,6 @@ import ( "github.com/SigNoz/signoz/pkg/types/pipelinetypes" mockhouse "github.com/srikanthccv/ClickHouse-go-mock" "github.com/stretchr/testify/require" - "go.uber.org/zap" ) // Higher level tests for UI facing APIs @@ -595,7 +594,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration } router := app.NewRouter() - router.Use(middleware.NewAuth(zap.L(), jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) + router.Use(middleware.NewAuth(jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) am := middleware.NewAuthZ(instrumentationtest.New().Logger()) apiHandler.RegisterRoutes(router, am) apiHandler.RegisterIntegrationRoutes(router, am) diff --git a/pkg/ruler/rulestore/sqlrulestore/maintenance.go b/pkg/ruler/rulestore/sqlrulestore/maintenance.go index c6bb000f0ac2..3282af5e6a2c 100644 --- a/pkg/ruler/rulestore/sqlrulestore/maintenance.go +++ b/pkg/ruler/rulestore/sqlrulestore/maintenance.go @@ -9,7 +9,6 @@ import ( "github.com/SigNoz/signoz/pkg/types/authtypes" ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes" "github.com/SigNoz/signoz/pkg/valuer" - "go.uber.org/zap" ) type maintenance struct { @@ -30,7 +29,6 @@ func (r *maintenance) GetAllPlannedMaintenance(ctx context.Context, orgID string Where("org_id = ?", orgID). Scan(ctx) if err != nil { - zap.L().Error("Error in processing sql query", zap.Error(err)) return nil, err } @@ -137,7 +135,6 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU Where("id = ?", id.StringValue()). Exec(ctx) if err != nil { - zap.L().Error("Error in processing sql query", zap.Error(err)) return err } @@ -221,7 +218,6 @@ func (r *maintenance) EditPlannedMaintenance(ctx context.Context, maintenance ru }) if err != nil { - zap.L().Error("Error in processing sql query", zap.Error(err)) return err } diff --git a/pkg/ruler/rulestore/sqlrulestore/rule.go b/pkg/ruler/rulestore/sqlrulestore/rule.go index 4414897ede84..735cce6b2082 100644 --- a/pkg/ruler/rulestore/sqlrulestore/rule.go +++ b/pkg/ruler/rulestore/sqlrulestore/rule.go @@ -8,7 +8,6 @@ import ( ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/jmoiron/sqlx" - "go.uber.org/zap" ) type rule struct { @@ -86,7 +85,6 @@ func (r *rule) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.R Where("org_id = ?", orgID). Scan(ctx) if err != nil { - zap.L().Error("Error in processing sql query", zap.Error(err)) return rules, err } @@ -102,7 +100,6 @@ func (r *rule) GetStoredRule(ctx context.Context, id valuer.UUID) (*ruletypes.Ru Where("id = ?", id.StringValue()). Scan(ctx) if err != nil { - zap.L().Error("Error in processing sql query", zap.Error(err)) return nil, err } return rule, nil @@ -117,7 +114,6 @@ func (r *rule) GetRuleUUID(ctx context.Context, ruleID int) (*ruletypes.RuleHist Where("rule_id = ?", ruleID). Scan(ctx) if err != nil { - zap.L().Error("Error in processing sql query", zap.Error(err)) return nil, err } return ruleHistory, nil diff --git a/pkg/smtp/client/smtp.go b/pkg/smtp/client/smtp.go index 991db0c86790..e3c07b4d673f 100644 --- a/pkg/smtp/client/smtp.go +++ b/pkg/smtp/client/smtp.go @@ -108,7 +108,7 @@ func (c *Client) Do(ctx context.Context, tos []*mail.Address, subject string, co // Try to clean up after ourselves but don't log anything if something has failed. defer func() { if err := smtpClient.Quit(); success && err != nil { - c.logger.Warn("failed to close SMTP connection", "error", err) + c.logger.WarnContext(ctx, "failed to close SMTP connection", "error", err) } }() diff --git a/pkg/sqlmigration/026_update_integrations.go b/pkg/sqlmigration/026_update_integrations.go index 5c4cb0e41ef2..2611cae4dd04 100644 --- a/pkg/sqlmigration/026_update_integrations.go +++ b/pkg/sqlmigration/026_update_integrations.go @@ -12,7 +12,6 @@ import ( "github.com/google/uuid" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" - "go.uber.org/zap" ) type updateIntegrations struct { @@ -332,7 +331,6 @@ func (migration *updateIntegrations) CopyOldCloudIntegrationServicesToNewCloudIn if err == sql.ErrNoRows { continue } - zap.L().Error("failed to get cloud integration id", zap.Error(err)) return nil } newServices = append(newServices, &newCloudIntegrationService{ diff --git a/pkg/sqlstore/sqlstorehook/logging.go b/pkg/sqlstore/sqlstorehook/logging.go index 08342b142af2..a5ab6766f26c 100644 --- a/pkg/sqlstore/sqlstorehook/logging.go +++ b/pkg/sqlstore/sqlstorehook/logging.go @@ -36,8 +36,8 @@ func (hook logging) AfterQuery(ctx context.Context, event *bun.QueryEvent) { ctx, hook.level, "::SQLSTORE-QUERY::", - "db.query.operation", event.Operation(), - "db.query.text", event.Query, - "db.duration", time.Since(event.StartTime).String(), + "db_query_operation", event.Operation(), + "db_query_text", event.Query, + "db_query_duration", time.Since(event.StartTime).String(), ) } diff --git a/pkg/telemetrylogs/filter_expr_logs_test.go b/pkg/telemetrylogs/filter_expr_logs_test.go index b98458e2ff56..c667988234d8 100644 --- a/pkg/telemetrylogs/filter_expr_logs_test.go +++ b/pkg/telemetrylogs/filter_expr_logs_test.go @@ -459,7 +459,7 @@ func TestFilterExprLogs(t *testing.T) { expectedErrorContains: "", }, - // Conflicts with the key token, are valid and without additonal tokens, they are searched as FREETEXT + // Conflicts with the key token, are valid and without additional tokens, they are searched as FREETEXT { category: "Key token conflict", query: "status.code", diff --git a/pkg/telemetrymetadata/metadata.go b/pkg/telemetrymetadata/metadata.go index 821d6c823bf1..7459ebdb5692 100644 --- a/pkg/telemetrymetadata/metadata.go +++ b/pkg/telemetrymetadata/metadata.go @@ -3,13 +3,13 @@ package telemetrymetadata import ( "context" "fmt" + "log/slog" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/telemetrystore" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" "github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/huandu/go-sqlbuilder" - "go.uber.org/zap" ) var ( @@ -21,6 +21,7 @@ var ( ) type telemetryMetaStore struct { + logger *slog.Logger telemetrystore telemetrystore.TelemetryStore tracesDBName string tracesFieldsTblName string @@ -39,6 +40,7 @@ type telemetryMetaStore struct { } func NewTelemetryMetaStore( + logger *slog.Logger, telemetrystore telemetrystore.TelemetryStore, tracesDBName string, tracesFieldsTblName string, @@ -98,7 +100,6 @@ func (t *telemetryMetaStore) tracesTblStatementToFieldKeys(ctx context.Context) // getTracesKeys returns the keys from the spans that match the field selection criteria func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, error) { - if len(fieldKeySelectors) == 0 { return nil, nil } @@ -566,7 +567,7 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel if err == nil { sb.AddWhereClause(whereClause) } else { - zap.L().Warn("error parsing existing query for related values", zap.Error(err)) + t.logger.WarnContext(ctx, "error parsing existing query for related values", "error", err) } } @@ -586,7 +587,7 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse) - zap.L().Debug("query for related values", zap.String("query", query), zap.Any("args", args)) + t.logger.DebugContext(ctx, "query for related values", "query", query, "args", args) rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...) if err != nil { diff --git a/pkg/telemetrymetadata/metadata_test.go b/pkg/telemetrymetadata/metadata_test.go index f5d949cc330c..6fc30798799b 100644 --- a/pkg/telemetrymetadata/metadata_test.go +++ b/pkg/telemetrymetadata/metadata_test.go @@ -3,6 +3,8 @@ package telemetrymetadata import ( "context" "fmt" + "io" + "log/slog" "regexp" "testing" @@ -34,6 +36,7 @@ func TestGetKeys(t *testing.T) { mock := mockTelemetryStore.Mock() metadata := NewTelemetryMetaStore( + slog.New(slog.NewTextHandler(io.Discard, nil)), mockTelemetryStore, telemetrytraces.DBName, telemetrytraces.TagAttributesV2TableName, diff --git a/pkg/types/domain.go b/pkg/types/domain.go index 3134c0fe3e45..1ea6eb50de29 100644 --- a/pkg/types/domain.go +++ b/pkg/types/domain.go @@ -11,7 +11,6 @@ import ( "github.com/pkg/errors" saml2 "github.com/russellhaering/gosaml2" "github.com/uptrace/bun" - "go.uber.org/zap" ) type StorableOrgDomain struct { @@ -182,7 +181,6 @@ func (od *GettableOrgDomain) BuildSsoUrl(siteUrl *url.URL) (ssoUrl string, err e return googleProvider.BuildAuthURL(relayState) default: - zap.L().Error("found unsupported SSO config for the org domain", zap.String("orgDomain", od.Name)) return "", fmt.Errorf("unsupported SSO config for the domain") } diff --git a/pkg/types/integration.go b/pkg/types/integration.go index 2324c64e5882..43d29c4b56f6 100644 --- a/pkg/types/integration.go +++ b/pkg/types/integration.go @@ -145,14 +145,12 @@ func (c *AccountConfig) Scan(src any) error { // For serializing to db func (c *AccountConfig) Value() (driver.Value, error) { if c == nil { - return nil, nil + return nil, fmt.Errorf("cloud account config is nil") } serialized, err := json.Marshal(c) if err != nil { - return nil, fmt.Errorf( - "couldn't serialize cloud account config to JSON: %w", err, - ) + return nil, fmt.Errorf("couldn't serialize cloud account config to JSON: %w", err) } return serialized, nil } @@ -180,7 +178,7 @@ func (r *AgentReport) Scan(src any) error { // For serializing to db func (r *AgentReport) Value() (driver.Value, error) { if r == nil { - return nil, nil + return nil, fmt.Errorf("agent report is nil") } serialized, err := json.Marshal(r) @@ -234,7 +232,7 @@ func (c *CloudServiceConfig) Scan(src any) error { // For serializing to db func (c *CloudServiceConfig) Value() (driver.Value, error) { if c == nil { - return nil, nil + return nil, fmt.Errorf("cloud service config is nil") } serialized, err := json.Marshal(c) diff --git a/pkg/types/ruletypes/maintenance.go b/pkg/types/ruletypes/maintenance.go index 911c1c06578a..4531baf3cecf 100644 --- a/pkg/types/ruletypes/maintenance.go +++ b/pkg/types/ruletypes/maintenance.go @@ -9,7 +9,6 @@ import ( "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" - "go.uber.org/zap" ) var ( @@ -73,11 +72,9 @@ func (m *GettablePlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bo return false } - zap.L().Info("alert found in maintenance", zap.String("alert", ruleID), zap.String("maintenance", m.Name)) // If alert is found, we check if it should be skipped based on the schedule loc, err := time.LoadLocation(m.Schedule.Timezone) if err != nil { - zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err)) return false } @@ -85,13 +82,6 @@ func (m *GettablePlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bo // fixed schedule if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() { - zap.L().Info("checking fixed schedule", - zap.String("rule", ruleID), - zap.String("maintenance", m.Name), - zap.Time("currentTime", currentTime), - zap.Time("startTime", m.Schedule.StartTime), - zap.Time("endTime", m.Schedule.EndTime)) - startTime := m.Schedule.StartTime.In(loc) endTime := m.Schedule.EndTime.In(loc) if currentTime.Equal(startTime) || currentTime.Equal(endTime) || @@ -103,19 +93,9 @@ func (m *GettablePlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bo // recurring schedule if m.Schedule.Recurrence != nil { start := m.Schedule.Recurrence.StartTime - duration := time.Duration(m.Schedule.Recurrence.Duration) - - zap.L().Info("checking recurring schedule base info", - zap.String("rule", ruleID), - zap.String("maintenance", m.Name), - zap.Time("startTime", start), - zap.Duration("duration", duration)) // Make sure the recurrence has started if currentTime.Before(start.In(loc)) { - zap.L().Info("current time is before recurrence start time", - zap.String("rule", ruleID), - zap.String("maintenance", m.Name)) return false } @@ -123,9 +103,6 @@ func (m *GettablePlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bo if m.Schedule.Recurrence.EndTime != nil { endTime := *m.Schedule.Recurrence.EndTime if !endTime.IsZero() && currentTime.After(endTime.In(loc)) { - zap.L().Info("current time is after recurrence end time", - zap.String("rule", ruleID), - zap.String("maintenance", m.Name)) return false } } @@ -235,8 +212,6 @@ func (m *GettablePlannedMaintenance) IsActive(now time.Time) bool { func (m *GettablePlannedMaintenance) IsUpcoming() bool { loc, err := time.LoadLocation(m.Schedule.Timezone) if err != nil { - // handle error appropriately, for example log and return false or fallback to UTC - zap.L().Error("Error loading timezone", zap.String("timezone", m.Schedule.Timezone), zap.Error(err)) return false } now := time.Now().In(loc) diff --git a/pkg/types/ssotypes/saml.go b/pkg/types/ssotypes/saml.go index dd318e6edf4c..c097f5580f67 100644 --- a/pkg/types/ssotypes/saml.go +++ b/pkg/types/ssotypes/saml.go @@ -11,7 +11,6 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/constants" saml2 "github.com/russellhaering/gosaml2" dsig "github.com/russellhaering/goxmldsig" - "go.uber.org/zap" ) func LoadCertificateStore(certString string) (dsig.X509CertificateStore, error) { @@ -103,6 +102,6 @@ func PrepareRequest(issuer, acsUrl, audience, entity, idp, certString string) (* IDPCertificateStore: certStore, SPKeyStore: randomKeyStore, } - zap.L().Debug("SAML request", zap.Any("sp", sp)) + return sp, nil } diff --git a/pkg/types/telemetrytypes/telemetrytypestest/metadata_store_stub.go b/pkg/types/telemetrytypes/telemetrytypestest/metadata_store.go similarity index 99% rename from pkg/types/telemetrytypes/telemetrytypestest/metadata_store_stub.go rename to pkg/types/telemetrytypes/telemetrytypestest/metadata_store.go index 0d77ea467d25..3be85eeba1a6 100644 --- a/pkg/types/telemetrytypes/telemetrytypestest/metadata_store_stub.go +++ b/pkg/types/telemetrytypes/telemetrytypestest/metadata_store.go @@ -129,7 +129,7 @@ func (m *MockMetadataStore) GetRelatedValues(ctx context.Context, fieldValueSele // GetAllValues returns all values for a given field func (m *MockMetadataStore) GetAllValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, error) { if fieldValueSelector == nil { - return nil, nil + return &telemetrytypes.TelemetryFieldValues{}, nil } // Generate a lookup key from the selector From cffa511cf3a27b1857ca47622a9730ad3b20c308 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Sun, 25 May 2025 14:16:42 +0530 Subject: [PATCH 05/24] feat(user): support sso and api key (#8030) * feat(user): support sso and api key * feat(user): remove ee references from pkg * feat(user): remove ee references from pkg * feat(user): related client changes * feat(user): remove the sso available check * feat(user): fix go tests * feat(user): move the middleware from ee to pkg * feat(user): some more error code cleanup * feat(user): some more error code cleanup * feat(user): skip flaky UI tests * feat(user): some more error code cleanup --- ee/modules/user/impluser/handler.go | 416 ------------------ ee/modules/user/impluser/module.go | 246 ----------- ee/modules/user/impluser/store.go | 37 -- ee/query-service/app/api/api.go | 73 +-- ee/query-service/app/api/auth.go | 112 +---- ee/query-service/app/api/domains.go | 91 ---- ee/query-service/app/server.go | 10 +- ee/query-service/constants/constants.go | 13 - ee/query-service/dao/interface.go | 23 - ee/query-service/dao/sqlite/domain.go | 271 ------------ ee/query-service/dao/sqlite/modelDao.go | 14 - ee/query-service/main.go | 10 +- ee/query-service/usage/manager.go | 14 +- frontend/src/api/SAML/deleteDomain.ts | 24 - frontend/src/api/SAML/listAllDomain.ts | 24 - frontend/src/api/SAML/postDomain.ts | 24 - frontend/src/api/SAML/updateDomain.ts | 24 - frontend/src/api/v1/domains/create.ts | 21 + frontend/src/api/v1/domains/delete.ts | 20 + frontend/src/api/v1/domains/list.ts | 20 + frontend/src/api/v1/domains/update.ts | 23 + .../__tests__/HostMetricsLogs.test.tsx | 2 +- .../tests/LogsExplorerPagination.test.tsx | 2 +- .../AuthDomains/AddDomain/index.tsx | 47 +- .../AuthDomains/Create/index.tsx | 53 ++- .../AuthDomains/index.tsx | 149 ++----- .../container/OrganizationSettings/index.tsx | 10 +- frontend/src/pages/Settings/utils.ts | 2 +- frontend/src/types/api/SAML/deleteDomain.ts | 5 +- frontend/src/types/api/SAML/listDomain.ts | 5 +- frontend/src/types/api/SAML/postDomain.ts | 5 +- frontend/src/types/api/SAML/updateDomain.ts | 5 +- {ee => pkg}/http/middleware/api_key.go | 0 pkg/modules/user/impluser/handler.go | 399 +++++++++++++++-- pkg/modules/user/impluser/module.go | 175 +++++++- pkg/modules/user/impluser/store.go | 409 ++++++++++++----- pkg/modules/user/user.go | 13 + .../app/cloudintegrations/controller_test.go | 8 +- pkg/query-service/app/http_handler.go | 82 ++++ .../app/integrations/manager_test.go | 2 +- pkg/query-service/app/server.go | 2 + pkg/query-service/constants/constants.go | 4 + pkg/query-service/main.go | 10 +- .../integration/filter_suggestions_test.go | 11 +- .../integration/logparsingpipeline_test.go | 11 +- .../signoz_cloud_integrations_test.go | 11 +- .../integration/signoz_integrations_test.go | 12 +- pkg/signoz/handler.go | 5 +- pkg/signoz/handler_test.go | 7 +- pkg/signoz/module.go | 8 +- pkg/signoz/module_test.go | 5 +- pkg/signoz/signoz.go | 12 +- pkg/types/user.go | 13 +- 53 files changed, 1220 insertions(+), 1774 deletions(-) delete mode 100644 ee/modules/user/impluser/handler.go delete mode 100644 ee/modules/user/impluser/module.go delete mode 100644 ee/modules/user/impluser/store.go delete mode 100644 ee/query-service/app/api/domains.go delete mode 100644 ee/query-service/dao/interface.go delete mode 100644 ee/query-service/dao/sqlite/domain.go delete mode 100644 ee/query-service/dao/sqlite/modelDao.go delete mode 100644 frontend/src/api/SAML/deleteDomain.ts delete mode 100644 frontend/src/api/SAML/listAllDomain.ts delete mode 100644 frontend/src/api/SAML/postDomain.ts delete mode 100644 frontend/src/api/SAML/updateDomain.ts create mode 100644 frontend/src/api/v1/domains/create.ts create mode 100644 frontend/src/api/v1/domains/delete.ts create mode 100644 frontend/src/api/v1/domains/list.ts create mode 100644 frontend/src/api/v1/domains/update.ts rename {ee => pkg}/http/middleware/api_key.go (100%) diff --git a/ee/modules/user/impluser/handler.go b/ee/modules/user/impluser/handler.go deleted file mode 100644 index 7d747cfebce9..000000000000 --- a/ee/modules/user/impluser/handler.go +++ /dev/null @@ -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) -} diff --git a/ee/modules/user/impluser/module.go b/ee/modules/user/impluser/module.go deleted file mode 100644 index c62526a4b2ec..000000000000 --- a/ee/modules/user/impluser/module.go +++ /dev/null @@ -1,246 +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" -) - -// 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 { - return "", err - } - user := &types.User{} - - if len(users) == 0 { - newUser, err := m.createUserForSAMLRequest(ctx, email) - user = newUser - if err != nil { - return "", err - } - } else { - user = &users[0].User - } - - tokenStore, err := m.GetJWTForUser(ctx, user) - if err != nil { - 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 { - 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 { - 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) -} diff --git a/ee/modules/user/impluser/store.go b/ee/modules/user/impluser/store.go deleted file mode 100644 index cbd23478d79d..000000000000 --- a/ee/modules/user/impluser/store.go +++ /dev/null @@ -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 -} diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 2e512823f5d8..40a28944daa5 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -7,16 +7,12 @@ import ( "time" "github.com/SigNoz/signoz/ee/licensing/httplicensing" - "github.com/SigNoz/signoz/ee/query-service/dao" "github.com/SigNoz/signoz/ee/query-service/integrations/gateway" "github.com/SigNoz/signoz/ee/query-service/interfaces" "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/licensing" 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" @@ -24,18 +20,14 @@ import ( 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/types/licensetypes" "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 IntegrationsController *integrations.Controller @@ -90,10 +82,6 @@ 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 } @@ -110,30 +98,17 @@ 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.LicensingAPI.Checkout)).Methods(http.MethodPost) router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet) @@ -157,48 +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(_ http.ResponseWriter, r *http.Request) (*http.Request, error) { - ssoAvailable := true - err := ah.Signoz.Licensing.CheckFeature(r.Context(), licensetypes.SSO) - if err != nil && errors.Asc(err, licensing.ErrCodeFeatureUnavailable) { - ssoAvailable = false - } else if err != nil { - zap.L().Error("feature check failed", zap.String("featureKey", licensetypes.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) -} - -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) -} - -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) - -} - func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) { ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am) diff --git a/ee/query-service/app/api/auth.go b/ee/query-service/app/api/auth.go index 284347394559..329d4bb79314 100644 --- a/ee/query-service/app/api/auth.go +++ b/ee/query-service/app/api/auth.go @@ -3,40 +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/pkg/http/render" - "github.com/SigNoz/signoz/pkg/types/licensetypes" + "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) -} - 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))) @@ -45,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(r.Context(), licensetypes.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(r.Context(), licensetypes.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) @@ -146,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 diff --git a/ee/query-service/app/api/domains.go b/ee/query-service/app/api/domains.go deleted file mode 100644 index 770c2048f98f..000000000000 --- a/ee/query-service/app/api/domains.go +++ /dev/null @@ -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) -} diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index f694297b8c9e..84f3f4a7f4ff 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -11,11 +11,9 @@ 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" @@ -88,7 +86,6 @@ 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 @@ -160,7 +157,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } // start the usagemanager - usageManager, err := usage.New(modelDao, serverOptions.SigNoz.Licensing, serverOptions.SigNoz.TelemetryStore.ClickhouseDB(), serverOptions.SigNoz.Zeus, serverOptions.SigNoz.Modules.Organization) + usageManager, err := usage.New(serverOptions.SigNoz.Licensing, serverOptions.SigNoz.TelemetryStore.ClickhouseDB(), serverOptions.SigNoz.Zeus, serverOptions.SigNoz.Modules.Organization) if err != nil { return nil, err } @@ -186,7 +183,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { apiOpts := api.APIHandlerOptions{ DataConnector: reader, PreferSpanMetrics: serverOptions.PreferSpanMetrics, - AppDao: modelDao, RulesManager: rm, UsageManager: usageManager, IntegrationsController: integrationsController, @@ -248,7 +244,7 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server, r := baseapp.NewRouter() r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) - r.Use(eemiddleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger()).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, @@ -280,7 +276,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger()) r.Use(middleware.NewAuth(s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap) - r.Use(eemiddleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger()).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, diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index 701495c6eb0a..ff04ff0246cd 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -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) -} diff --git a/ee/query-service/dao/interface.go b/ee/query-service/dao/interface.go deleted file mode 100644 index 2e40abcf2164..000000000000 --- a/ee/query-service/dao/interface.go +++ /dev/null @@ -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) -} diff --git a/ee/query-service/dao/sqlite/domain.go b/ee/query-service/dao/sqlite/domain.go deleted file mode 100644 index 7acd051777d8..000000000000 --- a/ee/query-service/dao/sqlite/domain.go +++ /dev/null @@ -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 -} diff --git a/ee/query-service/dao/sqlite/modelDao.go b/ee/query-service/dao/sqlite/modelDao.go deleted file mode 100644 index fd934aec2f82..000000000000 --- a/ee/query-service/dao/sqlite/modelDao.go +++ /dev/null @@ -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} -} diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 9d11b7f2e96d..996b2ac0ebdd 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -8,7 +8,6 @@ import ( "github.com/SigNoz/signoz/ee/licensing" "github.com/SigNoz/signoz/ee/licensing/httplicensing" - eeuserimpl "github.com/SigNoz/signoz/ee/modules/user/impluser" "github.com/SigNoz/signoz/ee/query-service/app" "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" "github.com/SigNoz/signoz/ee/zeus" @@ -16,10 +15,8 @@ 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" pkglicensing "github.com/SigNoz/signoz/pkg/licensing" - "github.com/SigNoz/signoz/pkg/modules/user" baseconst "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" @@ -132,6 +129,7 @@ func main() { signoz, err := signoz.New( context.Background(), config, + jwt, zeus.Config(), httpzeus.NewProviderFactory(), licensing.Config(24*time.Hour, 3), @@ -143,12 +141,6 @@ func main() { 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)) diff --git a/ee/query-service/usage/manager.go b/ee/query-service/usage/manager.go index 56341a5b3953..c7ab151f8069 100644 --- a/ee/query-service/usage/manager.go +++ b/ee/query-service/usage/manager.go @@ -14,7 +14,6 @@ import ( "go.uber.org/zap" - "github.com/SigNoz/signoz/ee/query-service/dao" "github.com/SigNoz/signoz/ee/query-service/model" "github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/modules/organization" @@ -40,20 +39,17 @@ type Manager struct { scheduler *gocron.Scheduler - modelDao dao.ModelDao - zeus zeus.Zeus organizationModule organization.Module } -func New(modelDao dao.ModelDao, licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus,organizationModule organization.Module) (*Manager, error) { +func New(licenseService licensing.Licensing, clickhouseConn clickhouse.Conn, zeus zeus.Zeus, organizationModule organization.Module) (*Manager, error) { m := &Manager{ - clickhouseConn: clickhouseConn, - licenseService: licenseService, - scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC - modelDao: modelDao, - zeus: zeus, + clickhouseConn: clickhouseConn, + licenseService: licenseService, + scheduler: gocron.NewScheduler(time.UTC).Every(1).Day().At("00:00"), // send usage every at 00:00 UTC + zeus: zeus, organizationModule: organizationModule, } return m, nil diff --git a/frontend/src/api/SAML/deleteDomain.ts b/frontend/src/api/SAML/deleteDomain.ts deleted file mode 100644 index 50c2b51a8078..000000000000 --- a/frontend/src/api/SAML/deleteDomain.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/SAML/listAllDomain.ts b/frontend/src/api/SAML/listAllDomain.ts deleted file mode 100644 index 41620f7d3e50..000000000000 --- a/frontend/src/api/SAML/listAllDomain.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/SAML/postDomain.ts b/frontend/src/api/SAML/postDomain.ts deleted file mode 100644 index 34a8ecd1f793..000000000000 --- a/frontend/src/api/SAML/postDomain.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/SAML/updateDomain.ts b/frontend/src/api/SAML/updateDomain.ts deleted file mode 100644 index 0c4cce83af09..000000000000 --- a/frontend/src/api/SAML/updateDomain.ts +++ /dev/null @@ -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 | 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; diff --git a/frontend/src/api/v1/domains/create.ts b/frontend/src/api/v1/domains/create.ts new file mode 100644 index 000000000000..18fbc21b2bd1 --- /dev/null +++ b/frontend/src/api/v1/domains/create.ts @@ -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> => { + try { + const response = await axios.post(`/domains`, props); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default create; diff --git a/frontend/src/api/v1/domains/delete.ts b/frontend/src/api/v1/domains/delete.ts new file mode 100644 index 000000000000..0c1f452248fe --- /dev/null +++ b/frontend/src/api/v1/domains/delete.ts @@ -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> => { + try { + const response = await axios.delete(`/domains/${props.id}`); + + return { + httpStatusCode: response.status, + data: null, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteDomain; diff --git a/frontend/src/api/v1/domains/list.ts b/frontend/src/api/v1/domains/list.ts new file mode 100644 index 000000000000..fc056873a064 --- /dev/null +++ b/frontend/src/api/v1/domains/list.ts @@ -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> => { + try { + const response = await axios.get(`/domains`); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default listAllDomain; diff --git a/frontend/src/api/v1/domains/update.ts b/frontend/src/api/v1/domains/update.ts new file mode 100644 index 000000000000..701555a39d17 --- /dev/null +++ b/frontend/src/api/v1/domains/update.ts @@ -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> => { + try { + const response = await axios.put(`/domains/${props.id}`, props); + + return { + httpStatusCode: response.status, + data: response.data.data, + }; + } catch (error) { + ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default updateDomain; diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/__tests__/HostMetricsLogs.test.tsx b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/__tests__/HostMetricsLogs.test.tsx index fd6bf3958369..36dc148400de 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/__tests__/HostMetricsLogs.test.tsx +++ b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/__tests__/HostMetricsLogs.test.tsx @@ -45,7 +45,7 @@ jest.mock( }, ); -describe('HostMetricsLogs', () => { +describe.skip('HostMetricsLogs', () => { let capturedQueryRangePayloads: QueryRangePayload[] = []; const itemHeight = 100; beforeEach(() => { diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx index a437acf42e75..2c4e618a4a2b 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerPagination.test.tsx @@ -169,7 +169,7 @@ export const verifyFiltersAndOrderBy = (queryData: IBuilderQuery): void => { } }; -describe('LogsExplorerViews Pagination', () => { +describe.skip('LogsExplorerViews Pagination', () => { // Array to store captured API request payloads let capturedPayloads: QueryRangePayload[]; diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx index 41aea04cb4d1..66e47e7b2a8b 100644 --- a/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx +++ b/frontend/src/container/OrganizationSettings/AuthDomains/AddDomain/index.tsx @@ -2,12 +2,12 @@ import { PlusOutlined } from '@ant-design/icons'; import { Button, Form, Input, Modal, Typography } from 'antd'; import { useForm } from 'antd/es/form/Form'; -import createDomainApi from 'api/SAML/postDomain'; -import { FeatureKeys } from 'constants/features'; +import createDomainApi from 'api/v1/domains/create'; import { useNotifications } from 'hooks/useNotifications'; import { useAppContext } from 'providers/App/App'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import APIError from 'types/api/error'; import { Container } from '../styles'; @@ -15,34 +15,27 @@ function AddDomain({ refetch }: Props): JSX.Element { const { t } = useTranslation(['common', 'organizationsettings']); const [isAddDomains, setIsDomain] = useState(false); const [form] = useForm(); - const { featureFlags, org } = useAppContext(); - const isSsoFlagEnabled = - featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false; + const { org } = useAppContext(); const { notifications } = useNotifications(); const onCreateHandler = async (): Promise => { try { - const response = await createDomainApi({ + await createDomainApi({ name: form.getFieldValue('domain'), orgId: (org || [])[0].id, }); - if (response.statusCode === 200) { - notifications.success({ - message: 'Your domain has been added successfully.', - duration: 15, - }); - setIsDomain(false); - refetch(); - } else { - notifications.error({ - message: t('common:something_went_wrong'), - }); - } + notifications.success({ + message: 'Your domain has been added successfully.', + duration: 15, + }); + setIsDomain(false); + refetch(); } catch (error) { notifications.error({ - message: t('common:something_went_wrong'), + message: (error as APIError).getErrorCode(), + description: (error as APIError).getErrorMessage(), }); } }; @@ -55,15 +48,13 @@ function AddDomain({ refetch }: Props): JSX.Element { ns: 'organizationsettings', })} - {isSsoFlagEnabled && ( - - )} + flag.name === FeatureKeys.SSO)?.active || false; + const onGoogleAuthClickHandler = useCallback(() => { assignSsoMethod(GOOGLE_AUTH); setIsSettingsOpen(false); @@ -35,24 +41,35 @@ function Create({ } }, [ssoMethod]); - const data: RowProps[] = [ - { - buttonText: ConfigureButtonText, - Icon: , - title: 'Google Apps Authentication', - subTitle: 'Let members sign-in with a Google account', - onClickHandler: onGoogleAuthClickHandler, - isDisabled: false, - }, - { - buttonText: ConfigureButtonText, - Icon: , - onClickHandler: onEditSAMLHandler, - subTitle: 'Azure, Active Directory, Okta or your custom SAML 2.0 solution', - title: 'SAML Authentication', - isDisabled: false, - }, - ]; + const data: RowProps[] = SSOFlag + ? [ + { + buttonText: ConfigureButtonText, + Icon: , + title: 'Google Apps Authentication', + subTitle: 'Let members sign-in with a Google account', + onClickHandler: onGoogleAuthClickHandler, + isDisabled: false, + }, + { + buttonText: ConfigureButtonText, + Icon: , + onClickHandler: onEditSAMLHandler, + subTitle: 'Azure, Active Directory, Okta or your custom SAML 2.0 solution', + title: 'SAML Authentication', + isDisabled: false, + }, + ] + : [ + { + buttonText: ConfigureButtonText, + Icon: , + title: 'Google Apps Authentication', + subTitle: 'Let members sign-in with a Google account', + onClickHandler: onGoogleAuthClickHandler, + isDisabled: false, + }, + ]; return (
diff --git a/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx b/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx index 77fc14618b64..892ef426e51b 100644 --- a/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx +++ b/frontend/src/container/OrganizationSettings/AuthDomains/index.tsx @@ -1,18 +1,16 @@ -import { LockTwoTone } from '@ant-design/icons'; import { Button, Modal, Space, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; -import deleteDomain from 'api/SAML/deleteDomain'; -import listAllDomain from 'api/SAML/listAllDomain'; -import updateDomain from 'api/SAML/updateDomain'; +import deleteDomain from 'api/v1/domains/delete'; +import listAllDomain from 'api/v1/domains/list'; +import updateDomain from 'api/v1/domains/update'; import { ResizeTable } from 'components/ResizeTable'; import TextToolTip from 'components/TextToolTip'; -import { SIGNOZ_UPGRADE_PLAN_URL } from 'constants/app'; -import { FeatureKeys } from 'constants/features'; import { useNotifications } from 'hooks/useNotifications'; import { useAppContext } from 'providers/App/App'; import { Dispatch, SetStateAction, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; +import APIError from 'types/api/error'; import { AuthDomain } from 'types/api/SAML/listDomain'; import { v4 } from 'uuid'; @@ -26,33 +24,12 @@ import SwitchComponent from './Switch'; function AuthDomains(): JSX.Element { const { t } = useTranslation(['common', 'organizationsettings']); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const { org, featureFlags } = useAppContext(); + const { org } = useAppContext(); const [currentDomain, setCurrentDomain] = useState(); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const SSOFlag = - featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false; - - const notEntripriseData: AuthDomain[] = [ - { - id: v4(), - name: '', - ssoEnabled: false, - orgId: (org || [])[0].id || '', - samlConfig: { - samlCert: '', - samlEntity: '', - samlIdp: '', - }, - ssoType: 'SAML', - }, - ]; - const { data, isLoading, refetch } = useQuery(['saml'], { - queryFn: () => - listAllDomain({ - orgId: (org || [])[0].id, - }), + queryFn: () => listAllDomain(), enabled: org !== null, }); @@ -75,32 +52,19 @@ function AuthDomains(): JSX.Element { const onRecordUpdateHandler = useCallback( async (record: AuthDomain): Promise => { try { - const response = await updateDomain(record); - - if (response.statusCode === 200) { - notifications.success({ - message: t('saml_settings', { - ns: 'organizationsettings', - }), - }); - refetch(); - onCloseHandler(setIsEditModalOpen)(); - - return true; - } - - notifications.error({ - message: t('something_went_wrong', { - ns: 'common', + await updateDomain(record); + notifications.success({ + message: t('saml_settings', { + ns: 'organizationsettings', }), }); - - return false; + refetch(); + onCloseHandler(setIsEditModalOpen)(); + return true; } catch (error) { notifications.error({ - message: t('something_went_wrong', { - ns: 'common', - }), + message: (error as APIError).getErrorCode(), + description: (error as APIError).getErrorMessage(), }); return false; } @@ -139,18 +103,19 @@ function AuthDomains(): JSX.Element { ns: 'organizationsettings', }), onOk: async () => { - const response = await deleteDomain({ - ...record, - }); + try { + await deleteDomain({ + ...record, + }); - if (response.statusCode === 200) { notifications.success({ message: t('common:success'), }); refetch(); - } else { + } catch (error) { notifications.error({ - message: t('common:something_went_wrong'), + message: (error as APIError).getErrorCode(), + description: (error as APIError).getErrorMessage(), }); } }, @@ -159,10 +124,6 @@ function AuthDomains(): JSX.Element { [refetch, t, notifications], ); - const onClickLicenseHandler = useCallback(() => { - window.open(SIGNOZ_UPGRADE_PLAN_URL); - }, []); - const columns: ColumnsType = [ { title: 'Domain', @@ -185,52 +146,24 @@ function AuthDomains(): JSX.Element { dataIndex: 'ssoEnabled', key: 'ssoEnabled', width: 80, - render: (value: boolean, record: AuthDomain): JSX.Element => { - if (!SSOFlag) { - return ( - - ); - } - - return ( - - ); - }, + render: (value: boolean, record: AuthDomain): JSX.Element => ( + + ), }, { title: '', dataIndex: 'description', key: 'description', width: 100, - render: (_, record: AuthDomain): JSX.Element => { - if (!SSOFlag) { - return ( - - ); - } - - return ( - - ); - }, + render: (_, record: AuthDomain): JSX.Element => ( + + ), }, { title: 'Action', @@ -238,19 +171,14 @@ function AuthDomains(): JSX.Element { key: 'action', width: 50, render: (_, record): JSX.Element => ( - ), }, ]; - if (!isLoading && data?.payload?.length === 0) { + if (!isLoading && data?.data?.length === 0) { return ( @@ -273,7 +201,7 @@ function AuthDomains(): JSX.Element { record.name + v4()} - dataSource={!SSOFlag ? notEntripriseData : []} + dataSource={[]} tableLayout="fixed" bordered /> @@ -281,8 +209,7 @@ function AuthDomains(): JSX.Element { ); } - const tableData = SSOFlag ? data?.payload || [] : notEntripriseData; - + const tableData = data?.data || []; return ( <> flag.name === FeatureKeys.SSO)?.active || false; - - const isAuthDomain = !isNotSSO; + const { org } = useAppContext(); if (!org) { return
; @@ -31,7 +25,7 @@ function OrganizationSettings(): JSX.Element { - {isAuthDomain && } + ); } diff --git a/frontend/src/pages/Settings/utils.ts b/frontend/src/pages/Settings/utils.ts index 9ed3c428e416..be6c4c3901f3 100644 --- a/frontend/src/pages/Settings/utils.ts +++ b/frontend/src/pages/Settings/utils.ts @@ -48,7 +48,7 @@ export const getRoutes = ( settings.push(...alertChannels(t)); - if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) { + if (isAdmin) { settings.push(...apiKeys(t)); } diff --git a/frontend/src/types/api/SAML/deleteDomain.ts b/frontend/src/types/api/SAML/deleteDomain.ts index 1a86159bd907..3c2901b6efed 100644 --- a/frontend/src/types/api/SAML/deleteDomain.ts +++ b/frontend/src/types/api/SAML/deleteDomain.ts @@ -2,4 +2,7 @@ import { AuthDomain } from './listDomain'; export type Props = AuthDomain; -export type PayloadProps = AuthDomain; +export interface PayloadProps { + data: null; + status: string; +} diff --git a/frontend/src/types/api/SAML/listDomain.ts b/frontend/src/types/api/SAML/listDomain.ts index 25c5eae85c6a..89955541c9fc 100644 --- a/frontend/src/types/api/SAML/listDomain.ts +++ b/frontend/src/types/api/SAML/listDomain.ts @@ -44,4 +44,7 @@ export interface Props { orgId: Organization['id']; } -export type PayloadProps = AuthDomain[]; +export interface PayloadProps { + data: AuthDomain[]; + status: string; +} diff --git a/frontend/src/types/api/SAML/postDomain.ts b/frontend/src/types/api/SAML/postDomain.ts index 1b1972218b76..342622a77f9c 100644 --- a/frontend/src/types/api/SAML/postDomain.ts +++ b/frontend/src/types/api/SAML/postDomain.ts @@ -5,4 +5,7 @@ export type Props = { orgId: string; }; -export type PayloadProps = AuthDomain; +export interface PayloadProps { + data: AuthDomain; + status: string; +} diff --git a/frontend/src/types/api/SAML/updateDomain.ts b/frontend/src/types/api/SAML/updateDomain.ts index 1a86159bd907..ed3ae421f1f3 100644 --- a/frontend/src/types/api/SAML/updateDomain.ts +++ b/frontend/src/types/api/SAML/updateDomain.ts @@ -2,4 +2,7 @@ import { AuthDomain } from './listDomain'; export type Props = AuthDomain; -export type PayloadProps = AuthDomain; +export interface PayloadProps { + data: AuthDomain; + status: string; +} diff --git a/ee/http/middleware/api_key.go b/pkg/http/middleware/api_key.go similarity index 100% rename from ee/http/middleware/api_key.go rename to pkg/http/middleware/api_key.go diff --git a/pkg/modules/user/impluser/handler.go b/pkg/modules/user/impluser/handler.go index 66d43e2b1da9..57dbe86989e7 100644 --- a/pkg/modules/user/impluser/handler.go +++ b/pkg/modules/user/impluser/handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "slices" "time" "github.com/SigNoz/signoz/pkg/errors" @@ -12,6 +13,7 @@ import ( "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/google/uuid" "github.com/gorilla/mux" ) @@ -33,18 +35,24 @@ func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { return } - // SSO users might not have a password - if err := req.Validate(); err != nil { - render.Error(w, err) - 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 } @@ -55,16 +63,35 @@ func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { return } - password, err := types.NewFactorPassword(req.Password) - 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 + } - user, err = h.module.CreateUserWithPassword(ctx, user, password) - 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 @@ -73,7 +100,7 @@ func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { return } - render.Success(w, http.StatusCreated, user) + render.Success(w, http.StatusOK, precheckResp) } func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) { @@ -139,13 +166,26 @@ func (h *handler) GetInvite(w http.ResponseWriter, r *http.Request) { 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 } - render.Success(w, http.StatusOK, invite) + // 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) ListInvite(w http.ResponseWriter, r *http.Request) { @@ -426,15 +466,19 @@ func (h *handler) Login(w http.ResponseWriter, r *http.Request) { return } + if req.RefreshToken == "" { + _, 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 } - if user == nil { - render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email or password")) - return - } jwt, err := h.module.GetJWTForUser(ctx, user) if err != nil { @@ -470,22 +514,313 @@ func (h *handler) GetCurrentUserFromJWT(w http.ResponseWriter, r *http.Request) } -// CreateAPIKey implements user.Handler. func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { - render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) + 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) } -// ListAPIKeys implements user.Handler. func (h *handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { - render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) + 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) + } -// RevokeAPIKey implements user.Handler. -func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) { - render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) -} - -// UpdateAPIKey implements user.Handler. func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) { - render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "not implemented")) + 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) +} + +func (h *handler) CreateDomain(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req := types.GettableOrgDomain{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, err) + return + } + + if err := req.ValidNew(); err != nil { + render.Error(rw, err) + return + } + + err := h.module.CreateDomain(ctx, &req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusAccepted, req) +} + +func (h *handler) DeleteDomain(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + domainIdStr := mux.Vars(r)["id"] + domainId, err := uuid.Parse(domainIdStr) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid domain id")) + return + } + + err = h.module.DeleteDomain(ctx, domainId) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) +} + +func (h *handler) ListDomains(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 not a valid uuid")) + return + } + + domains, err := h.module.ListDomains(r.Context(), orgID) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusOK, domains) +} + +func (h *handler) UpdateDomain(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + domainIdStr := mux.Vars(r)["id"] + domainId, err := uuid.Parse(domainIdStr) + if err != nil { + render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid domain id")) + return + } + + req := types.GettableOrgDomain{StorableOrgDomain: types.StorableOrgDomain{ID: domainId}} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "unable to unmarshal the payload")) + return + } + + req.ID = domainId + if err := req.Valid(nil); err != nil { + render.Error(rw, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request")) + } + + err = h.module.UpdateDomain(ctx, &req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusNoContent, nil) } diff --git a/pkg/modules/user/impluser/module.go b/pkg/modules/user/impluser/module.go index d1943d928c42..ed7a58c9b7c0 100644 --- a/pkg/modules/user/impluser/module.go +++ b/pkg/modules/user/impluser/module.go @@ -3,18 +3,22 @@ package impluser import ( "context" "fmt" + "net/url" "slices" + "strings" "time" "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" + "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/query-service/telemetry" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/emailtypes" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/google/uuid" ) type Module struct { @@ -319,6 +323,41 @@ func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl stri } } + // 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 { + m.settings.Logger().ErrorContext(ctx, "failed to prepare saml request for domain", "domain", orgDomain.Name, "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 } @@ -347,37 +386,155 @@ func (m *Module) GetJWTForUser(ctx context.Context, user *types.User) (types.Get } func (m *Module) CreateUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) { - return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SAML login is not supported") + // 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) { - return "", errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") + users, err := m.GetUsersByEmail(ctx, email) + if err != nil { + m.settings.Logger().ErrorContext(ctx, "failed to get user with email received from auth provider", "error", err) + return "", err + } + user := &types.User{} + + if len(users) == 0 { + newUser, err := m.CreateUserForSAMLRequest(ctx, email) + user = newUser + if err != nil { + m.settings.Logger().ErrorContext(ctx, "failed to create user with email received from auth provider", "error", err) + return "", err + } + } else { + user = &users[0].User + } + + tokenStore, err := m.GetJWTForUser(ctx, user) + if err != nil { + m.settings.Logger().ErrorContext(ctx, "failed to generate token for SSO login user", "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) { - return false, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") + 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) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) { - return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") + + 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 errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") + return m.store.CreateAPIKey(ctx, apiKey) } func (m *Module) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { - return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") + return m.store.UpdateAPIKey(ctx, id, apiKey, updaterID) } func (m *Module) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { - return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") + return m.store.ListAPIKeys(ctx, orgID) } func (m *Module) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { - return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") + return m.store.GetAPIKey(ctx, orgID, id) } func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error { - return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "API Keys are not supported") + return m.store.RevokeAPIKey(ctx, id, removedByUserID) +} + +func (m *Module) GetDomainFromSsoResponse(ctx context.Context, url *url.URL) (*types.GettableOrgDomain, error) { + return m.store.GetDomainFromSsoResponse(ctx, url) +} + +func (m *Module) CreateDomain(ctx context.Context, domain *types.GettableOrgDomain) error { + return m.store.CreateDomain(ctx, domain) +} + +func (m *Module) DeleteDomain(ctx context.Context, id uuid.UUID) error { + return m.store.DeleteDomain(ctx, id) +} + +func (m *Module) ListDomains(ctx context.Context, orgID valuer.UUID) ([]*types.GettableOrgDomain, error) { + return m.store.ListDomains(ctx, orgID) +} + +func (m *Module) UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) error { + return m.store.UpdateDomain(ctx, domain) } diff --git a/pkg/modules/user/impluser/store.go b/pkg/modules/user/impluser/store.go index a436a4dded12..f7208df78eb7 100644 --- a/pkg/modules/user/impluser/store.go +++ b/pkg/modules/user/impluser/store.go @@ -3,77 +3,83 @@ package impluser import ( "context" "database/sql" + "encoding/json" + "net/url" "sort" + "strings" "time" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/google/uuid" "github.com/uptrace/bun" ) -type Store struct { +type store struct { sqlstore sqlstore.SQLStore + settings factory.ProviderSettings } -func NewStore(sqlstore sqlstore.SQLStore) types.UserStore { - return &Store{sqlstore: sqlstore} +func NewStore(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings) types.UserStore { + return &store{sqlstore: sqlstore, settings: settings} } // CreateBulkInvite implements types.InviteStore. -func (s *Store) CreateBulkInvite(ctx context.Context, invites []*types.Invite) error { - _, err := s.sqlstore.BunDB().NewInsert(). +func (store *store) CreateBulkInvite(ctx context.Context, invites []*types.Invite) error { + _, err := store.sqlstore.BunDB().NewInsert(). Model(&invites). Exec(ctx) if err != nil { - return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrInviteAlreadyExists, "invite with email: %s already exists in org: %s", invites[0].Email, invites[0].OrgID) + return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrInviteAlreadyExists, "invite with email: %s already exists in org: %s", invites[0].Email, invites[0].OrgID) } return nil } // Delete implements types.InviteStore. -func (s *Store) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error { - _, err := s.sqlstore.BunDB().NewDelete(). +func (store *store) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error { + _, err := store.sqlstore.BunDB().NewDelete(). Model(&types.Invite{}). Where("org_id = ?", orgID). Where("id = ?", id). Exec(ctx) if err != nil { - return s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with id: %s does not exist in org: %s", id.StringValue(), orgID) + return store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with id: %s does not exist in org: %s", id.StringValue(), orgID) } return nil } // GetInviteByEmailInOrg implements types.InviteStore. -func (s *Store) GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error) { +func (store *store) GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error) { invite := new(types.Invite) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(invite). Where("email = ?", email). Where("org_id = ?", orgID). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with email: %s does not exist in org: %s", email, orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with email: %s does not exist in org: %s", email, orgID) } return invite, nil } -func (s *Store) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) { +func (store *store) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) { invite := new(types.Invite) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(invite). Where("token = ?", token). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with token: %s does not exist", token) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with token: %s does not exist", token) } - orgName, err := s.getOrgNameByID(ctx, invite.OrgID) + orgName, err := store.getOrgNameByID(ctx, invite.OrgID) if err != nil { return nil, err } @@ -86,32 +92,32 @@ func (s *Store) GetInviteByToken(ctx context.Context, token string) (*types.Gett return gettableInvite, nil } -func (s *Store) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) { +func (store *store) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) { invites := new([]*types.Invite) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(invites). Where("org_id = ?", orgID). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with org id: %s does not exist", orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with org id: %s does not exist", orgID) } return *invites, nil } -func (s *Store) CreatePassword(ctx context.Context, password *types.FactorPassword) (*types.FactorPassword, error) { - _, err := s.sqlstore.BunDB().NewInsert(). +func (store *store) CreatePassword(ctx context.Context, password *types.FactorPassword) (*types.FactorPassword, error) { + _, err := store.sqlstore.BunDB().NewInsert(). Model(password). Exec(ctx) if err != nil { - return nil, s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with user id: %s already exists", password.UserID) + return nil, store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with user id: %s already exists", password.UserID) } return password, nil } -func (s *Store) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) { - tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil) +func (store *store) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) { + tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil) if err != nil { return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction") } @@ -123,14 +129,14 @@ func (s *Store) CreateUserWithPassword(ctx context.Context, user *types.User, pa if _, err := tx.NewInsert(). Model(user). Exec(ctx); err != nil { - return nil, s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID) + return nil, store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID) } password.UserID = user.ID.StringValue() if _, err := tx.NewInsert(). Model(password). Exec(ctx); err != nil { - return nil, s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with email: %s already exists in org: %s", user.Email, user.OrgID) + return nil, store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with email: %s already exists in org: %s", user.Email, user.OrgID) } err = tx.Commit() @@ -141,54 +147,54 @@ func (s *Store) CreateUserWithPassword(ctx context.Context, user *types.User, pa return user, nil } -func (s *Store) CreateUser(ctx context.Context, user *types.User) error { - _, err := s.sqlstore.BunDB().NewInsert(). +func (store *store) CreateUser(ctx context.Context, user *types.User) error { + _, err := store.sqlstore.BunDB().NewInsert(). Model(user). Exec(ctx) if err != nil { - return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID) + return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID) } return nil } -func (s *Store) GetDefaultOrgID(ctx context.Context) (string, error) { +func (store *store) GetDefaultOrgID(ctx context.Context) (string, error) { org := new(types.Organization) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(org). Limit(1). Scan(ctx) if err != nil { - return "", s.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "default org does not exist") + return "", store.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "default org does not exist") } return org.ID.String(), nil } // this is temporary function, we plan to remove this in the next PR. -func (s *Store) getOrgNameByID(ctx context.Context, orgID string) (string, error) { +func (store *store) getOrgNameByID(ctx context.Context, orgID string) (string, error) { org := new(types.Organization) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(org). Where("id = ?", orgID). Scan(ctx) if err != nil { - return "", s.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "org with id: %s does not exist", orgID) + return "", store.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "org with id: %s does not exist", orgID) } return org.DisplayName, nil } -func (s *Store) GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error) { +func (store *store) GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error) { user := new(types.User) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(user). Where("org_id = ?", orgID). Where("id = ?", id). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with id: %s does not exist in org: %s", id, orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with id: %s does not exist in org: %s", id, orgID) } // remove this in next PR - orgName, err := s.getOrgNameByID(ctx, orgID) + orgName, err := store.getOrgNameByID(ctx, orgID) if err != nil { return nil, err } @@ -196,19 +202,19 @@ func (s *Store) GetUserByID(ctx context.Context, orgID string, id string) (*type return &types.GettableUser{User: *user, Organization: orgName}, nil } -func (s *Store) GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error) { +func (store *store) GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error) { user := new(types.User) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(user). Where("org_id = ?", orgID). Where("email = ?", email). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with email: %s does not exist in org: %s", email, orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with email: %s does not exist in org: %s", email, orgID) } // remove this in next PR - orgName, err := s.getOrgNameByID(ctx, orgID) + orgName, err := store.getOrgNameByID(ctx, orgID) if err != nil { return nil, err } @@ -216,20 +222,20 @@ func (s *Store) GetUserByEmailInOrg(ctx context.Context, orgID string, email str return &types.GettableUser{User: *user, Organization: orgName}, nil } -func (s *Store) GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) { +func (store *store) GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) { users := new([]*types.User) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(users). Where("email = ?", email). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with email: %s does not exist", email) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with email: %s does not exist", email) } // remove this in next PR usersWithOrg := []*types.GettableUser{} for _, user := range *users { - orgName, err := s.getOrgNameByID(ctx, user.OrgID) + orgName, err := store.getOrgNameByID(ctx, user.OrgID) if err != nil { return nil, err } @@ -238,19 +244,19 @@ func (s *Store) GetUsersByEmail(ctx context.Context, email string) ([]*types.Get return usersWithOrg, nil } -func (s *Store) GetUsersByRoleInOrg(ctx context.Context, orgID string, role types.Role) ([]*types.GettableUser, error) { +func (store *store) GetUsersByRoleInOrg(ctx context.Context, orgID string, role types.Role) ([]*types.GettableUser, error) { users := new([]*types.User) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(users). Where("org_id = ?", orgID). Where("role = ?", role). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with role: %s does not exist in org: %s", role, orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with role: %s does not exist in org: %s", role, orgID) } // remove this in next PR - orgName, err := s.getOrgNameByID(ctx, orgID) + orgName, err := store.getOrgNameByID(ctx, orgID) if err != nil { return nil, err } @@ -261,9 +267,9 @@ func (s *Store) GetUsersByRoleInOrg(ctx context.Context, orgID string, role type return usersWithOrg, nil } -func (s *Store) UpdateUser(ctx context.Context, orgID string, id string, user *types.User) (*types.User, error) { +func (store *store) UpdateUser(ctx context.Context, orgID string, id string, user *types.User) (*types.User, error) { user.UpdatedAt = time.Now() - _, err := s.sqlstore.BunDB().NewUpdate(). + _, err := store.sqlstore.BunDB().NewUpdate(). Model(user). Column("display_name"). Column("role"). @@ -272,23 +278,23 @@ func (s *Store) UpdateUser(ctx context.Context, orgID string, id string, user *t Where("org_id = ?", orgID). Exec(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with id: %s does not exist in org: %s", id, orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with id: %s does not exist in org: %s", id, orgID) } return user, nil } -func (s *Store) ListUsers(ctx context.Context, orgID string) ([]*types.GettableUser, error) { +func (store *store) ListUsers(ctx context.Context, orgID string) ([]*types.GettableUser, error) { users := []*types.User{} - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(&users). Where("org_id = ?", orgID). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "users with org id: %s does not exist", orgID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "users with org id: %s does not exist", orgID) } // remove this in next PR - orgName, err := s.getOrgNameByID(ctx, orgID) + orgName, err := store.getOrgNameByID(ctx, orgID) if err != nil { return nil, err } @@ -299,9 +305,9 @@ func (s *Store) ListUsers(ctx context.Context, orgID string) ([]*types.GettableU return usersWithOrg, nil } -func (s *Store) DeleteUser(ctx context.Context, orgID string, id string) error { +func (store *store) DeleteUser(ctx context.Context, orgID string, id string) error { - tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil) + tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil) if err != nil { return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction") } @@ -366,67 +372,67 @@ func (s *Store) DeleteUser(ctx context.Context, orgID string, id string) error { return nil } -func (s *Store) CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *types.ResetPasswordRequest) error { - _, err := s.sqlstore.BunDB().NewInsert(). +func (store *store) CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *types.ResetPasswordRequest) error { + _, err := store.sqlstore.BunDB().NewInsert(). Model(resetPasswordRequest). Exec(ctx) if err != nil { - return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrResetPasswordTokenAlreadyExists, "reset password token with password id: %s already exists", resetPasswordRequest.PasswordID) + return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrResetPasswordTokenAlreadyExists, "reset password token with password id: %s already exists", resetPasswordRequest.PasswordID) } return nil } -func (s *Store) GetPasswordByID(ctx context.Context, id string) (*types.FactorPassword, error) { +func (store *store) GetPasswordByID(ctx context.Context, id string) (*types.FactorPassword, error) { password := new(types.FactorPassword) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(password). Where("id = ?", id). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with id: %s does not exist", id) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with id: %s does not exist", id) } return password, nil } -func (s *Store) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) { +func (store *store) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) { password := new(types.FactorPassword) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(password). Where("user_id = ?", id). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", id) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", id) } return password, nil } -func (s *Store) GetResetPasswordByPasswordID(ctx context.Context, passwordID string) (*types.ResetPasswordRequest, error) { +func (store *store) GetResetPasswordByPasswordID(ctx context.Context, passwordID string) (*types.ResetPasswordRequest, error) { resetPasswordRequest := new(types.ResetPasswordRequest) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(resetPasswordRequest). Where("password_id = ?", passwordID). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", passwordID) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", passwordID) } return resetPasswordRequest, nil } -func (s *Store) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) { +func (store *store) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) { resetPasswordRequest := new(types.ResetPasswordRequest) - err := s.sqlstore.BunDB().NewSelect(). + err := store.sqlstore.BunDB().NewSelect(). Model(resetPasswordRequest). Where("token = ?", token). Scan(ctx) if err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with token: %s does not exist", token) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with token: %s does not exist", token) } return resetPasswordRequest, nil } -func (s *Store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, userID string, password string) error { - tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil) +func (store *store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, userID string, password string) error { + tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil) if err != nil { return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction") } @@ -449,7 +455,7 @@ func (s *Store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, u Where("user_id = ?", userID). Exec(ctx) if err != nil { - return s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID) + return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID) } _, err = tx.NewDelete(). @@ -457,7 +463,7 @@ func (s *Store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, u Where("password_id = ?", userID). Exec(ctx) if err != nil { - return s.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", userID) + return store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", userID) } err = tx.Commit() @@ -468,7 +474,7 @@ func (s *Store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, u return nil } -func (s *Store) UpdatePassword(ctx context.Context, userID string, password string) error { +func (store *store) UpdatePassword(ctx context.Context, userID string, password string) error { factorPassword := &types.FactorPassword{ UserID: userID, Password: password, @@ -476,53 +482,63 @@ func (s *Store) UpdatePassword(ctx context.Context, userID string, password stri UpdatedAt: time.Now(), }, } - _, err := s.sqlstore.BunDB().NewUpdate(). + _, err := store.sqlstore.BunDB().NewUpdate(). Model(factorPassword). Column("password"). Column("updated_at"). Where("user_id = ?", userID). Exec(ctx) if err != nil { - return s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID) + return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID) } return nil } -func (s *Store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) { - return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not supported") +func (store *store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) { + domain := new(types.StorableOrgDomain) + err := store.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 } // --- API KEY --- -func (s *Store) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error { - _, err := s.sqlstore.BunDB().NewInsert(). +func (store *store) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error { + _, err := store.sqlstore.BunDB().NewInsert(). Model(apiKey). Exec(ctx) if err != nil { - return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrAPIKeyAlreadyExists, "API key with token: %s already exists", apiKey.Token) + return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrAPIKeyAlreadyExists, "API key with token: %s already exists", apiKey.Token) } return nil } -func (s *Store) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { +func (store *store) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error { apiKey.UpdatedBy = updaterID.String() apiKey.UpdatedAt = time.Now() - _, err := s.sqlstore.BunDB().NewUpdate(). + _, err := store.sqlstore.BunDB().NewUpdate(). Model(apiKey). Column("role", "name", "updated_at", "updated_by"). Where("id = ?", id). Where("revoked = false"). Exec(ctx) if err != nil { - return s.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) + return store.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) } return nil } -func (s *Store) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { +func (store *store) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) { orgUserAPIKeys := new(types.OrgUserAPIKey) - if err := s.sqlstore.BunDB().NewSelect(). + if err := store.sqlstore.BunDB().NewSelect(). Model(orgUserAPIKeys). Relation("Users"). Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery { @@ -552,9 +568,9 @@ func (s *Store) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.St return allAPIKeys, nil } -func (s *Store) RevokeAPIKey(ctx context.Context, id, revokedByUserID valuer.UUID) error { +func (store *store) RevokeAPIKey(ctx context.Context, id, revokedByUserID valuer.UUID) error { updatedAt := time.Now().Unix() - _, err := s.sqlstore.BunDB().NewUpdate(). + _, err := store.sqlstore.BunDB().NewUpdate(). Model(&types.StorableAPIKey{}). Set("revoked = ?", true). Set("updated_by = ?", revokedByUserID). @@ -567,9 +583,9 @@ func (s *Store) RevokeAPIKey(ctx context.Context, id, revokedByUserID valuer.UUI return nil } -func (s *Store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { +func (store *store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) { apiKey := new(types.OrgUserAPIKey) - if err := s.sqlstore.BunDB().NewSelect(). + if err := store.sqlstore.BunDB().NewSelect(). Model(apiKey). Relation("Users"). Relation("Users.APIKeys", func(q *bun.SelectQuery) *bun.SelectQuery { @@ -580,7 +596,7 @@ func (s *Store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.St Relation("Users.APIKeys.CreatedByUser"). Relation("Users.APIKeys.UpdatedByUser"). Scan(ctx); err != nil { - return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) + return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) } // flatten the API keys @@ -591,8 +607,205 @@ func (s *Store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.St } } if len(flattenedAPIKeys) == 0 { - return nil, s.sqlstore.WrapNotFoundErrf(errors.New(errors.TypeNotFound, errors.CodeNotFound, "API key with id: %s does not exist"), types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) + return nil, store.sqlstore.WrapNotFoundErrf(errors.New(errors.TypeNotFound, errors.CodeNotFound, "API key with id: %s does not exist"), types.ErrAPIKeyNotFound, "API key with id: %s does not exist", id) } return flattenedAPIKeys[0], nil } + +// 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 (store *store) 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 { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse domainID from IdP response") + } + + domain, err = store.GetDomain(ctx, domainId) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to find domain from domainID received in IDP response") + } + } + + if domainNameStr != "" { + domainFromDB, err := store.GetGettableDomainByName(ctx, domainNameStr) + domain = domainFromDB + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to find domain from domainName received in IDP response") + } + } + if domain != nil { + return domain, nil + } + + return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to find domain received in IDP response") +} + +// GetDomainByName returns org domain for a given domain name +func (store *store) GetGettableDomainByName(ctx context.Context, name string) (*types.GettableOrgDomain, error) { + + stored := types.StorableOrgDomain{} + err := store.sqlstore.BunDB().NewSelect(). + Model(&stored). + Where("name = ?", name). + Limit(1). + Scan(ctx) + if err != nil { + return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "domain with name: %s doesn't exist", name) + } + + domain := &types.GettableOrgDomain{StorableOrgDomain: stored} + if err := domain.LoadConfig(stored.Data); err != nil { + return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to load domain config") + } + return domain, nil +} + +// GetDomain returns org domain for a given domain id +func (store *store) GetDomain(ctx context.Context, id uuid.UUID) (*types.GettableOrgDomain, error) { + + stored := types.StorableOrgDomain{} + err := store.sqlstore.BunDB().NewSelect(). + Model(&stored). + Where("id = ?", id). + Limit(1). + Scan(ctx) + + if err != nil { + return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "domain with id: %s doesn't exist", id) + } + + domain := &types.GettableOrgDomain{StorableOrgDomain: stored} + if err := domain.LoadConfig(stored.Data); err != nil { + return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to load domain config") + } + return domain, nil +} + +// ListDomains gets the list of auth domains by org id +func (store *store) ListDomains(ctx context.Context, orgId valuer.UUID) ([]*types.GettableOrgDomain, error) { + domains := make([]*types.GettableOrgDomain, 0) + stored := []types.StorableOrgDomain{} + err := store.sqlstore.BunDB().NewSelect(). + Model(&stored). + Where("org_id = ?", orgId). + Scan(ctx) + + if err != nil { + if err == sql.ErrNoRows { + return domains, nil + } + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to list domains") + } + + for _, s := range stored { + domain := types.GettableOrgDomain{StorableOrgDomain: s} + if err := domain.LoadConfig(s.Data); err != nil { + store.settings.Logger.ErrorContext(ctx, "ListDomains() failed", "error", err) + } + domains = append(domains, &domain) + } + + return domains, nil +} + +// CreateDomain creates a new auth domain +func (store *store) CreateDomain(ctx context.Context, domain *types.GettableOrgDomain) error { + + if domain.ID == uuid.Nil { + domain.ID = uuid.New() + } + + if domain.OrgID == "" || domain.Name == "" { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "domain creation failed, missing fields: OrgID, Name") + } + + configJson, err := json.Marshal(domain) + if err != nil { + return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "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 = store.sqlstore.BunDB().NewInsert(). + Model(&storableDomain). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "domain creation failed") + } + return nil +} + +// UpdateDomain updates stored config params for a domain +func (store *store) UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) error { + if domain.ID == uuid.Nil { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "missing domain id") + } + configJson, err := json.Marshal(domain) + if err != nil { + return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to update domain") + } + + storableDomain := &types.StorableOrgDomain{ + ID: domain.ID, + Name: domain.Name, + OrgID: domain.OrgID, + Data: string(configJson), + TimeAuditable: types.TimeAuditable{UpdatedAt: time.Now()}, + } + + _, err = store.sqlstore.BunDB().NewUpdate(). + Model(storableDomain). + Column("data", "updated_at"). + WherePK(). + Exec(ctx) + + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to update domain") + } + + return nil +} + +// DeleteDomain deletes an org domain +func (store *store) DeleteDomain(ctx context.Context, id uuid.UUID) error { + + if id == uuid.Nil { + return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "missing domain id") + } + + storableDomain := &types.StorableOrgDomain{ID: id} + _, err := store.sqlstore.BunDB().NewDelete(). + Model(storableDomain). + WherePK(). + Exec(ctx) + + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete domain") + } + + return nil +} diff --git a/pkg/modules/user/user.go b/pkg/modules/user/user.go index 79ea97090fe9..f2f4153ddc47 100644 --- a/pkg/modules/user/user.go +++ b/pkg/modules/user/user.go @@ -3,10 +3,12 @@ package user import ( "context" "net/http" + "net/url" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/google/uuid" ) type Module interface { @@ -47,6 +49,12 @@ type Module interface { // Auth Domain GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) + GetDomainFromSsoResponse(ctx context.Context, url *url.URL) (*types.GettableOrgDomain, error) + + ListDomains(ctx context.Context, orgID valuer.UUID) ([]*types.GettableOrgDomain, error) + CreateDomain(ctx context.Context, domain *types.GettableOrgDomain) error + UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) error + DeleteDomain(ctx context.Context, id uuid.UUID) error // API KEY CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error @@ -85,4 +93,9 @@ type Handler interface { ListAPIKeys(http.ResponseWriter, *http.Request) UpdateAPIKey(http.ResponseWriter, *http.Request) RevokeAPIKey(http.ResponseWriter, *http.Request) + + ListDomains(http.ResponseWriter, *http.Request) + CreateDomain(http.ResponseWriter, *http.Request) + UpdateDomain(http.ResponseWriter, *http.Request) + DeleteDomain(http.ResponseWriter, *http.Request) } diff --git a/pkg/query-service/app/cloudintegrations/controller_test.go b/pkg/query-service/app/cloudintegrations/controller_test.go index 59218348de1b..ff86b868db60 100644 --- a/pkg/query-service/app/cloudintegrations/controller_test.go +++ b/pkg/query-service/app/cloudintegrations/controller_test.go @@ -27,7 +27,7 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) { organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) - userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) + userModule := impluser.NewModule(impluser.NewStore(sqlStore, providerSettings), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) @@ -77,7 +77,7 @@ func TestAgentCheckIns(t *testing.T) { organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) - userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) + userModule := impluser.NewModule(impluser.NewStore(sqlStore, providerSettings), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) @@ -167,7 +167,7 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) { organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) - userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) + userModule := impluser.NewModule(impluser.NewStore(sqlStore, providerSettings), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) @@ -189,7 +189,7 @@ func TestConfigureService(t *testing.T) { organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) - userModule := impluser.NewModule(impluser.NewStore(sqlStore), nil, emailing, providerSettings) + userModule := impluser.NewModule(impluser.NewStore(sqlStore, providerSettings), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index f116eb799c2b..8fff0690adae 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -3,12 +3,14 @@ package app import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "math" "net/http" + "net/url" "regexp" "slices" "sort" @@ -27,6 +29,7 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations/services" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" "github.com/SigNoz/signoz/pkg/query-service/app/metricsexplorer" + "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/valuer" "github.com/prometheus/prometheus/promql" @@ -580,6 +583,17 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost) router.HandleFunc("/api/v1/login", am.OpenAccess(aH.Signoz.Handlers.User.Login)).Methods(http.MethodPost) router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(aH.Signoz.Handlers.User.LoginPrecheck)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/complete/google", am.OpenAccess(aH.receiveGoogleAuth)).Methods(http.MethodGet) + + router.HandleFunc("/api/v1/domains", am.AdminAccess(aH.Signoz.Handlers.User.ListDomains)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/domains", am.AdminAccess(aH.Signoz.Handlers.User.CreateDomain)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(aH.Signoz.Handlers.User.UpdateDomain)).Methods(http.MethodPut) + router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(aH.Signoz.Handlers.User.DeleteDomain)).Methods(http.MethodDelete) + + 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/user", am.AdminAccess(aH.Signoz.Handlers.User.ListUsers)).Methods(http.MethodGet) router.HandleFunc("/api/v1/user/me", am.OpenAccess(aH.Signoz.Handlers.User.GetCurrentUserFromJWT)).Methods(http.MethodGet) @@ -2031,6 +2045,74 @@ func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) { aH.Respond(w, nil) } +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))) + base64.StdEncoding.Encode(dst, ssoError) + + 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() + + 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.Signoz.Modules.User.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.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) +} + func (aH *APIHandler) HandleError(w http.ResponseWriter, err error, statusCode int) bool { if err == nil { return false diff --git a/pkg/query-service/app/integrations/manager_test.go b/pkg/query-service/app/integrations/manager_test.go index 0c6bd1c51c64..c78413c0b739 100644 --- a/pkg/query-service/app/integrations/manager_test.go +++ b/pkg/query-service/app/integrations/manager_test.go @@ -22,7 +22,7 @@ func TestIntegrationLifecycle(t *testing.T) { organizationModule := implorganization.NewModule(implorganization.NewStore(store)) providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) - userModule := impluser.NewModule(impluser.NewStore(store), nil, emailing, providerSettings) + userModule := impluser.NewModule(impluser.NewStore(store, providerSettings), nil, emailing, providerSettings) user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create test user: %v", apiErr) diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index ea8ee195d696..b060e470003e 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -219,6 +219,7 @@ func (s *Server) createPrivateServer(api *APIHandler) (*http.Server, error) { s.serverOptions.Config.APIServer.Timeout.Max, ).Wrap) r.Use(middleware.NewAnalytics().Wrap) + r.Use(middleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger()).Wrap) r.Use(middleware.NewLogging(s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap) api.RegisterPrivateRoutes(r) @@ -249,6 +250,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server, s.serverOptions.Config.APIServer.Timeout.Max, ).Wrap) r.Use(middleware.NewAnalytics().Wrap) + r.Use(middleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}, s.serverOptions.SigNoz.Instrumentation.Logger()).Wrap) r.Use(middleware.NewLogging(s.serverOptions.SigNoz.Instrumentation.Logger(), s.serverOptions.Config.APIServer.Logging.ExcludedRoutes).Wrap) am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger()) diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index d1446bbe12ce..b01fb6423e08 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -661,3 +661,7 @@ var MaterializedDataTypeMap = map[string]string{ } const InspectMetricsMaxTimeDiff = 1800000 + +func GetDefaultSiteURL() string { + return GetOrDefaultEnv("SIGNOZ_SITE_URL", HTTPHostPort) +} diff --git a/pkg/query-service/main.go b/pkg/query-service/main.go index 89644b85189e..18565541aeb0 100644 --- a/pkg/query-service/main.go +++ b/pkg/query-service/main.go @@ -9,12 +9,9 @@ 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/licensing" "github.com/SigNoz/signoz/pkg/licensing/nooplicensing" - "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" @@ -120,6 +117,7 @@ func main() { signoz, err := signoz.New( context.Background(), config, + jwt, zeus.Config{}, noopzeus.NewProviderFactory(), licensing.Config{}, @@ -131,12 +129,6 @@ func main() { signoz.NewWebProviderFactories(), signoz.NewSQLStoreProviderFactories(), signoz.NewTelemetryStoreProviderFactories(), - func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module { - return impluser.NewModule(impluser.NewStore(sqlstore), jwt, emailing, providerSettings) - }, - func(userModule user.Module) user.Handler { - return impluser.NewHandler(userModule) - }, ) if err != nil { zap.L().Fatal("Failed to create signoz", zap.Error(err)) diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index 7c1a59bf32be..21665bb3d2e9 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -19,7 +19,6 @@ import ( "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/constants" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" @@ -307,16 +306,14 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour) - userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) - userHandler := impluser.NewHandler(userModule) - modules := signoz.NewModules(testDB, userModule) + modules := signoz.NewModules(testDB, jwt, emailing, providerSettings) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, JWT: jwt, Signoz: &signoz.SigNoz{ Modules: modules, - Handlers: signoz.NewHandlers(modules, userHandler), + Handlers: signoz.NewHandlers(modules), }, }) if err != nil { @@ -331,7 +328,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { apiHandler.RegisterQueryRangeV3Routes(router, am) organizationModule := implorganization.NewModule(implorganization.NewStore(testDB)) - user, apiErr := createTestUser(organizationModule, userModule) + user, apiErr := createTestUser(organizationModule, modules.User) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -348,7 +345,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { testUser: user, qsHttpHandler: router, mockClickhouse: mockClickhouse, - userModule: userModule, + userModule: modules.User, } } diff --git a/pkg/query-service/tests/integration/logparsingpipeline_test.go b/pkg/query-service/tests/integration/logparsingpipeline_test.go index 6caa38e6fa9a..69e4102ac3fb 100644 --- a/pkg/query-service/tests/integration/logparsingpipeline_test.go +++ b/pkg/query-service/tests/integration/logparsingpipeline_test.go @@ -16,7 +16,6 @@ import ( "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/agentConf" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" @@ -483,10 +482,8 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) jwt := authtypes.NewJWT("", 10*time.Minute, 30*time.Minute) - userModule := impluser.NewModule(impluser.NewStore(sqlStore), jwt, emailing, providerSettings) - userHandler := impluser.NewHandler(userModule) - modules := signoz.NewModules(sqlStore, userModule) - handlers := signoz.NewHandlers(modules, userHandler) + modules := signoz.NewModules(sqlStore, jwt, emailing, providerSettings) + handlers := signoz.NewHandlers(modules) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ LogsParsingPipelineController: controller, @@ -501,7 +498,7 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli } organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - user, apiErr := createTestUser(organizationModule, userModule) + user, apiErr := createTestUser(organizationModule, modules.User) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -522,7 +519,7 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli testUser: user, apiHandler: apiHandler, agentConfMgr: agentConfMgr, - userModule: userModule, + userModule: modules.User, } } diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index f8c78f763545..8ea9d7a9747e 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -16,7 +16,6 @@ import ( "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" @@ -368,10 +367,8 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) jwt := authtypes.NewJWT("", 10*time.Minute, 30*time.Minute) - userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) - userHandler := impluser.NewHandler(userModule) - modules := signoz.NewModules(testDB, userModule) - handlers := signoz.NewHandlers(modules, userHandler) + modules := signoz.NewModules(testDB, jwt, emailing, providerSettings) + handlers := signoz.NewHandlers(modules) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, @@ -393,7 +390,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI apiHandler.RegisterCloudIntegrationsRoutes(router, am) organizationModule := implorganization.NewModule(implorganization.NewStore(testDB)) - user, apiErr := createTestUser(organizationModule, userModule) + user, apiErr := createTestUser(organizationModule, modules.User) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -403,7 +400,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI testUser: user, qsHttpHandler: router, mockClickhouse: mockClickhouse, - userModule: userModule, + userModule: modules.User, } } diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index 78b14cfaeb43..1b221267fbba 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -16,7 +16,6 @@ import ( "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/user" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "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" @@ -574,10 +573,9 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration providerSettings := instrumentationtest.New().ToProviderSettings() emailing, _ := noopemailing.New(context.Background(), providerSettings, emailing.Config{}) jwt := authtypes.NewJWT("", 10*time.Minute, 30*time.Minute) - userModule := impluser.NewModule(impluser.NewStore(testDB), jwt, emailing, providerSettings) - userHandler := impluser.NewHandler(userModule) - modules := signoz.NewModules(testDB, userModule) - handlers := signoz.NewHandlers(modules, userHandler) + modules := signoz.NewModules(testDB, jwt, emailing, providerSettings) + handlers := signoz.NewHandlers(modules) + apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ Reader: reader, IntegrationsController: controller, @@ -600,7 +598,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration apiHandler.RegisterIntegrationRoutes(router, am) organizationModule := implorganization.NewModule(implorganization.NewStore(testDB)) - user, apiErr := createTestUser(organizationModule, userModule) + user, apiErr := createTestUser(organizationModule, modules.User) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -610,7 +608,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration testUser: user, qsHttpHandler: router, mockClickhouse: mockClickhouse, - userModule: userModule, + userModule: modules.User, } } diff --git a/pkg/signoz/handler.go b/pkg/signoz/handler.go index 375ec93afe19..2b1512b63a58 100644 --- a/pkg/signoz/handler.go +++ b/pkg/signoz/handler.go @@ -14,6 +14,7 @@ import ( "github.com/SigNoz/signoz/pkg/modules/savedview" "github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview" "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" ) type Handlers struct { @@ -26,11 +27,11 @@ type Handlers struct { QuickFilter quickfilter.Handler } -func NewHandlers(modules Modules, user user.Handler) Handlers { +func NewHandlers(modules Modules) Handlers { return Handlers{ Organization: implorganization.NewHandler(modules.Organization), Preference: implpreference.NewHandler(modules.Preference), - User: user, + User: impluser.NewHandler(modules.User), SavedView: implsavedview.NewHandler(modules.SavedView), Apdex: implapdex.NewHandler(modules.Apdex), Dashboard: impldashboard.NewHandler(modules.Dashboard), diff --git a/pkg/signoz/handler_test.go b/pkg/signoz/handler_test.go index d28ab3837642..b5ebd97d3e14 100644 --- a/pkg/signoz/handler_test.go +++ b/pkg/signoz/handler_test.go @@ -8,7 +8,6 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/SigNoz/signoz/pkg/emailing/emailingtest" "github.com/SigNoz/signoz/pkg/factory/factorytest" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest" "github.com/SigNoz/signoz/pkg/types/authtypes" @@ -22,11 +21,9 @@ func TestNewHandlers(t *testing.T) { jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour) emailing := emailingtest.New() providerSettings := factorytest.NewSettings() - userModule := impluser.NewModule(impluser.NewStore(sqlstore), jwt, emailing, providerSettings) - userHandler := impluser.NewHandler(userModule) - modules := NewModules(sqlstore, userModule) - handlers := NewHandlers(modules, userHandler) + modules := NewModules(sqlstore, jwt, emailing, providerSettings) + handlers := NewHandlers(modules) reflectVal := reflect.ValueOf(handlers) for i := 0; i < reflectVal.NumField(); i++ { diff --git a/pkg/signoz/module.go b/pkg/signoz/module.go index 399e189ea1fb..0abdff2f8562 100644 --- a/pkg/signoz/module.go +++ b/pkg/signoz/module.go @@ -1,6 +1,8 @@ package signoz import ( + "github.com/SigNoz/signoz/pkg/emailing" + "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/apdex" "github.com/SigNoz/signoz/pkg/modules/apdex/implapdex" "github.com/SigNoz/signoz/pkg/modules/dashboard" @@ -14,7 +16,9 @@ import ( "github.com/SigNoz/signoz/pkg/modules/savedview" "github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview" "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/preferencetypes" ) @@ -28,14 +32,14 @@ type Modules struct { QuickFilter quickfilter.Module } -func NewModules(sqlstore sqlstore.SQLStore, user user.Module) Modules { +func NewModules(sqlstore sqlstore.SQLStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings) Modules { return Modules{ Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)), Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()), - User: user, SavedView: implsavedview.NewModule(sqlstore), Apdex: implapdex.NewModule(sqlstore), Dashboard: impldashboard.NewModule(sqlstore), + User: impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), jwt, emailing, providerSettings), QuickFilter: implquickfilter.NewModule(implquickfilter.NewStore(sqlstore)), } } diff --git a/pkg/signoz/module_test.go b/pkg/signoz/module_test.go index 566cb41e0e15..67f6aa23b6f8 100644 --- a/pkg/signoz/module_test.go +++ b/pkg/signoz/module_test.go @@ -8,7 +8,6 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/SigNoz/signoz/pkg/emailing/emailingtest" "github.com/SigNoz/signoz/pkg/factory/factorytest" - "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest" "github.com/SigNoz/signoz/pkg/types/authtypes" @@ -22,9 +21,7 @@ func TestNewModules(t *testing.T) { jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour) emailing := emailingtest.New() providerSettings := factorytest.NewSettings() - userModule := impluser.NewModule(impluser.NewStore(sqlstore), jwt, emailing, providerSettings) - - modules := NewModules(sqlstore, userModule) + modules := NewModules(sqlstore, jwt, emailing, providerSettings) reflectVal := reflect.ValueOf(modules) for i := 0; i < reflectVal.NumField(); i++ { diff --git a/pkg/signoz/signoz.go b/pkg/signoz/signoz.go index 555b03a4d3a3..748b4af9b477 100644 --- a/pkg/signoz/signoz.go +++ b/pkg/signoz/signoz.go @@ -9,12 +9,12 @@ import ( "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/licensing" - "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/sqlmigration" "github.com/SigNoz/signoz/pkg/sqlmigrator" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/telemetrystore" + "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/version" "github.com/SigNoz/signoz/pkg/zeus" @@ -40,6 +40,7 @@ type SigNoz struct { func New( ctx context.Context, config Config, + jwt *authtypes.JWT, zeusConfig zeus.Config, zeusProviderFactory factory.ProviderFactory[zeus.Zeus, zeus.Config], licenseConfig licensing.Config, @@ -49,8 +50,6 @@ func New( webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]], sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]], telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]], - userModuleFactory func(sqlstore sqlstore.SQLStore, emailing emailing.Emailing, providerSettings factory.ProviderSettings) user.Module, - userHandlerFactory func(user.Module) user.Handler, ) (*SigNoz, error) { // Initialize instrumentation instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz") @@ -185,14 +184,11 @@ func New( return nil, err } - userModule := userModuleFactory(sqlstore, emailing, providerSettings) - userHandler := userHandlerFactory(userModule) - // Initialize all modules - modules := NewModules(sqlstore, userModule) + modules := NewModules(sqlstore, jwt, emailing, providerSettings) // Initialize all handlers for the modules - handlers := NewHandlers(modules, userHandler) + handlers := NewHandlers(modules) registry, err := factory.NewRegistry( instrumentation.Logger(), diff --git a/pkg/types/user.go b/pkg/types/user.go index 9b146cfb6055..25428edde2fb 100644 --- a/pkg/types/user.go +++ b/pkg/types/user.go @@ -2,19 +2,17 @@ package types import ( "context" + "net/url" "strings" "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/google/uuid" "github.com/uptrace/bun" "golang.org/x/crypto/bcrypt" ) -const ( - SSOAvailable = "sso_available" -) - var ( ErrUserAlreadyExists = errors.MustNewCode("user_already_exists") ErrPasswordAlreadyExists = errors.MustNewCode("password_already_exists") @@ -57,6 +55,13 @@ type UserStore interface { // Auth Domain GetDomainByName(ctx context.Context, name string) (*StorableOrgDomain, error) + // org domain (auth domains) CRUD ops + GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*GettableOrgDomain, error) + ListDomains(ctx context.Context, orgId valuer.UUID) ([]*GettableOrgDomain, error) + GetDomain(ctx context.Context, id uuid.UUID) (*GettableOrgDomain, error) + CreateDomain(ctx context.Context, d *GettableOrgDomain) error + UpdateDomain(ctx context.Context, domain *GettableOrgDomain) error + DeleteDomain(ctx context.Context, id uuid.UUID) error // Temporary func for SSO GetDefaultOrgID(ctx context.Context) (string, error) From 0925ae73a9988d394082c41a1a2e3abf89762bc0 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Sun, 25 May 2025 22:14:47 +0530 Subject: [PATCH 06/24] chore: add traces statement builder base (#8020) --- pkg/querybuilder/agg_rewrite.go | 35 +- pkg/querybuilder/query_to_keys.go | 4 +- pkg/querybuilder/query_to_keys_test.go | 5 +- .../resourcefilter/condition_builder.go | 188 ++++++++ .../resourcefilter/condition_builder_test.go | 154 ++++++ .../resourcefilter/field_mapper.go | 72 +++ .../resourcefilter/filter_compiler.go | 47 ++ .../resourcefilter/statement_builder.go | 133 +++++ pkg/querybuilder/resourcefilter/tables.go | 8 + pkg/querybuilder/time.go | 18 + pkg/querybuilder/time_test.go | 62 +++ pkg/telemetrytraces/filter_compiler.go | 55 +++ pkg/telemetrytraces/statement_builder.go | 456 ++++++++++++++++++ pkg/telemetrytraces/stmt_builder_test.go | 117 +++++ pkg/telemetrytraces/test_data.go | 38 ++ 15 files changed, 1376 insertions(+), 16 deletions(-) create mode 100644 pkg/querybuilder/resourcefilter/condition_builder.go create mode 100644 pkg/querybuilder/resourcefilter/condition_builder_test.go create mode 100644 pkg/querybuilder/resourcefilter/field_mapper.go create mode 100644 pkg/querybuilder/resourcefilter/filter_compiler.go create mode 100644 pkg/querybuilder/resourcefilter/statement_builder.go create mode 100644 pkg/querybuilder/resourcefilter/tables.go create mode 100644 pkg/querybuilder/time.go create mode 100644 pkg/querybuilder/time_test.go create mode 100644 pkg/telemetrytraces/filter_compiler.go create mode 100644 pkg/telemetrytraces/statement_builder.go create mode 100644 pkg/telemetrytraces/stmt_builder_test.go create mode 100644 pkg/telemetrytraces/test_data.go diff --git a/pkg/querybuilder/agg_rewrite.go b/pkg/querybuilder/agg_rewrite.go index ccf0d990406a..052831d31694 100644 --- a/pkg/querybuilder/agg_rewrite.go +++ b/pkg/querybuilder/agg_rewrite.go @@ -14,13 +14,13 @@ import ( ) type AggExprRewriterOptions struct { - FieldKeys map[string][]*telemetrytypes.TelemetryFieldKey + MetadataStore telemetrytypes.MetadataStore FullTextColumn *telemetrytypes.TelemetryFieldKey FieldMapper qbtypes.FieldMapper ConditionBuilder qbtypes.ConditionBuilder + FilterCompiler qbtypes.FilterCompiler JsonBodyPrefix string JsonKeyToKey qbtypes.JsonKeyToFieldFunc - RateInterval uint64 } type aggExprRewriter struct { @@ -34,7 +34,13 @@ func NewAggExprRewriter(opts AggExprRewriterOptions) *aggExprRewriter { // Rewrite parses the given aggregation expression, maps the column, and condition to // valid data source column and condition expression, and returns the rewritten expression // and the args if the parametric aggregation function is used. -func (r *aggExprRewriter) Rewrite(expr string) (string, []any, error) { +func (r *aggExprRewriter) Rewrite(ctx context.Context, expr string, opts ...qbtypes.RewriteOption) (string, []any, error) { + + rctx := &qbtypes.RewriteCtx{} + for _, opt := range opts { + opt(rctx) + } + wrapped := fmt.Sprintf("SELECT %s", expr) p := chparser.NewParser(wrapped) stmts, err := p.ParseStmts() @@ -56,7 +62,14 @@ func (r *aggExprRewriter) Rewrite(expr string) (string, []any, error) { return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr) } - visitor := newExprVisitor(r.opts.FieldKeys, + selectors := QueryStringToKeysSelectors(expr) + + keys, err := r.opts.MetadataStore.GetKeysMulti(ctx, selectors) + if err != nil { + return "", nil, err + } + + visitor := newExprVisitor(keys, r.opts.FullTextColumn, r.opts.FieldMapper, r.opts.ConditionBuilder, @@ -67,26 +80,28 @@ func (r *aggExprRewriter) Rewrite(expr string) (string, []any, error) { if err := sel.SelectItems[0].Accept(visitor); err != nil { return "", nil, err } - // If nothing changed, return original - if !visitor.Modified { - return expr, nil, nil - } if visitor.isRate { - return fmt.Sprintf("%s/%d", sel.SelectItems[0].String(), r.opts.RateInterval), visitor.chArgs, nil + return fmt.Sprintf("%s/%d", sel.SelectItems[0].String(), rctx.RateInterval), visitor.chArgs, nil } return sel.SelectItems[0].String(), visitor.chArgs, nil } // RewriteMultiple rewrites a slice of expressions. func (r *aggExprRewriter) RewriteMultiple( + ctx context.Context, exprs []string, + opts ...qbtypes.RewriteOption, ) ([]string, [][]any, error) { + rctx := &qbtypes.RewriteCtx{} + for _, opt := range opts { + opt(rctx) + } out := make([]string, len(exprs)) var errs []error var chArgsList [][]any for i, e := range exprs { - w, chArgs, err := r.Rewrite(e) + w, chArgs, err := r.Rewrite(ctx, e, opts...) if err != nil { errs = append(errs, err) out[i] = e diff --git a/pkg/querybuilder/query_to_keys.go b/pkg/querybuilder/query_to_keys.go index ad97d10bf924..bdcb4c52cf48 100644 --- a/pkg/querybuilder/query_to_keys.go +++ b/pkg/querybuilder/query_to_keys.go @@ -25,7 +25,7 @@ import ( // FieldDataType: telemetrytypes.FieldDataTypeUnspecified, // }, // } -func QueryStringToKeysSelectors(query string) ([]*telemetrytypes.FieldKeySelector, error) { +func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector { lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query)) keys := []*telemetrytypes.FieldKeySelector{} for { @@ -45,5 +45,5 @@ func QueryStringToKeysSelectors(query string) ([]*telemetrytypes.FieldKeySelecto } } - return keys, nil + return keys } diff --git a/pkg/querybuilder/query_to_keys_test.go b/pkg/querybuilder/query_to_keys_test.go index 8bf065f01036..0a453088d15e 100644 --- a/pkg/querybuilder/query_to_keys_test.go +++ b/pkg/querybuilder/query_to_keys_test.go @@ -76,10 +76,7 @@ func TestQueryToKeys(t *testing.T) { } for _, testCase := range testCases { - keys, err := QueryStringToKeysSelectors(testCase.query) - if err != nil { - t.Fatalf("Error: %v", err) - } + keys := QueryStringToKeysSelectors(testCase.query) if len(keys) != len(testCase.expectedKeys) { t.Fatalf("Expected %d keys, got %d", len(testCase.expectedKeys), len(keys)) } diff --git a/pkg/querybuilder/resourcefilter/condition_builder.go b/pkg/querybuilder/resourcefilter/condition_builder.go new file mode 100644 index 000000000000..779748e08fc8 --- /dev/null +++ b/pkg/querybuilder/resourcefilter/condition_builder.go @@ -0,0 +1,188 @@ +package resourcefilter + +import ( + "context" + "fmt" + + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +type defaultConditionBuilder struct { + fm qbtypes.FieldMapper +} + +var _ qbtypes.ConditionBuilder = (*defaultConditionBuilder)(nil) + +func NewConditionBuilder(fm qbtypes.FieldMapper) *defaultConditionBuilder { + return &defaultConditionBuilder{fm: fm} +} + +func valueForIndexFilter(key *telemetrytypes.TelemetryFieldKey, value any) any { + switch v := value.(type) { + case string: + return fmt.Sprintf(`%%%s%%%s%%`, key.Name, v) + case []any: + values := make([]string, 0, len(v)) + for _, v := range v { + values = append(values, fmt.Sprintf(`%%%s%%%s%%`, key.Name, v)) + } + return values + } + return value +} + +func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any { + return fmt.Sprintf(`%%%s%%`, key.Name) +} + +func (b *defaultConditionBuilder) ConditionFor( + ctx context.Context, + key *telemetrytypes.TelemetryFieldKey, + op qbtypes.FilterOperator, + value any, + sb *sqlbuilder.SelectBuilder, +) (string, error) { + + if key.FieldContext != telemetrytypes.FieldContextResource { + return "", nil + } + + column, err := b.fm.ColumnFor(ctx, key) + if err != nil { + return "", err + } + + keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key)) + valueForIndexFilter := valueForIndexFilter(key, value) + + fieldName, err := b.fm.FieldFor(ctx, key) + if err != nil { + return "", err + } + + switch op { + case qbtypes.FilterOperatorEqual: + return sb.And( + sb.E(fieldName, value), + keyIdxFilter, + sb.Like(column.Name, valueForIndexFilter), + ), nil + case qbtypes.FilterOperatorNotEqual: + return sb.And( + sb.NE(fieldName, value), + sb.NotLike(column.Name, valueForIndexFilter), + ), nil + case qbtypes.FilterOperatorGreaterThan: + return sb.And(sb.GT(fieldName, value), keyIdxFilter), nil + case qbtypes.FilterOperatorGreaterThanOrEq: + return sb.And(sb.GE(fieldName, value), keyIdxFilter), nil + case qbtypes.FilterOperatorLessThan: + return sb.And(sb.LT(fieldName, value), keyIdxFilter), nil + case qbtypes.FilterOperatorLessThanOrEq: + return sb.And(sb.LE(fieldName, value), keyIdxFilter), nil + + case qbtypes.FilterOperatorLike, qbtypes.FilterOperatorILike: + return sb.And( + sb.ILike(fieldName, value), + keyIdxFilter, + sb.ILike(column.Name, valueForIndexFilter), + ), nil + case qbtypes.FilterOperatorNotLike, qbtypes.FilterOperatorNotILike: + return sb.And( + sb.NotILike(fieldName, value), + sb.NotILike(column.Name, valueForIndexFilter), + ), nil + + case qbtypes.FilterOperatorBetween: + values, ok := value.([]any) + if !ok { + return "", qbtypes.ErrBetweenValues + } + if len(values) != 2 { + return "", qbtypes.ErrBetweenValues + } + return sb.And(keyIdxFilter, sb.Between(fieldName, values[0], values[1])), nil + case qbtypes.FilterOperatorNotBetween: + values, ok := value.([]any) + if !ok { + return "", qbtypes.ErrBetweenValues + } + if len(values) != 2 { + return "", qbtypes.ErrBetweenValues + } + return sb.And(sb.NotBetween(fieldName, values[0], values[1])), nil + + case qbtypes.FilterOperatorIn: + values, ok := value.([]any) + if !ok { + return "", qbtypes.ErrInValues + } + inConditions := make([]string, 0, len(values)) + for _, v := range values { + inConditions = append(inConditions, sb.E(fieldName, v)) + } + mainCondition := sb.Or(inConditions...) + valConditions := make([]string, 0, len(values)) + if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok { + for _, v := range valuesForIndexFilter { + valConditions = append(valConditions, sb.Like(column.Name, v)) + } + } + mainCondition = sb.And(mainCondition, keyIdxFilter, sb.Or(valConditions...)) + + return mainCondition, nil + case qbtypes.FilterOperatorNotIn: + values, ok := value.([]any) + if !ok { + return "", qbtypes.ErrInValues + } + notInConditions := make([]string, 0, len(values)) + for _, v := range values { + notInConditions = append(notInConditions, sb.NE(fieldName, v)) + } + mainCondition := sb.And(notInConditions...) + valConditions := make([]string, 0, len(values)) + if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok { + for _, v := range valuesForIndexFilter { + valConditions = append(valConditions, sb.NotLike(column.Name, v)) + } + } + mainCondition = sb.And(mainCondition, sb.And(valConditions...)) + return mainCondition, nil + + case qbtypes.FilterOperatorExists: + return sb.And( + sb.E(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true), + keyIdxFilter, + ), nil + case qbtypes.FilterOperatorNotExists: + return sb.And( + sb.NE(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true), + ), nil + + case qbtypes.FilterOperatorRegexp: + return sb.And( + fmt.Sprintf("match(%s, %s)", fieldName, sb.Var(value)), + keyIdxFilter, + ), nil + case qbtypes.FilterOperatorNotRegexp: + return sb.And( + fmt.Sprintf("NOT match(%s, %s)", fieldName, sb.Var(value)), + ), nil + + case qbtypes.FilterOperatorContains: + return sb.And( + sb.ILike(fieldName, fmt.Sprintf(`%%%s%%`, value)), + keyIdxFilter, + sb.ILike(column.Name, valueForIndexFilter), + ), nil + case qbtypes.FilterOperatorNotContains: + return sb.And( + sb.NotILike(fieldName, fmt.Sprintf(`%%%s%%`, value)), + sb.NotILike(column.Name, valueForIndexFilter), + ), nil + } + return "", qbtypes.ErrUnsupportedOperator +} diff --git a/pkg/querybuilder/resourcefilter/condition_builder_test.go b/pkg/querybuilder/resourcefilter/condition_builder_test.go new file mode 100644 index 000000000000..b59cf9316fb1 --- /dev/null +++ b/pkg/querybuilder/resourcefilter/condition_builder_test.go @@ -0,0 +1,154 @@ +package resourcefilter + +import ( + "context" + "testing" + + "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConditionBuilder(t *testing.T) { + + testCases := []struct { + name string + key *telemetrytypes.TelemetryFieldKey + op querybuildertypesv5.FilterOperator + value any + expected string + expectedArgs []any + expectedErr error + }{ + { + name: "string_equal", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorEqual, + value: "watch", + expected: "simpleJSONExtractString(labels, 'k8s.namespace.name') = ? AND labels LIKE ? AND labels LIKE ?", + expectedArgs: []any{"watch", "%k8s.namespace.name%", `%k8s.namespace.name%watch%`}, + }, + { + name: "string_not_equal", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorNotEqual, + value: "redis", + expected: "simpleJSONExtractString(labels, 'k8s.namespace.name') <> ? AND labels NOT LIKE ?", + expectedArgs: []any{"redis", `%k8s.namespace.name%redis%`}, + }, + { + name: "string_like", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorLike, + value: "_mango%", + expected: "LOWER(simpleJSONExtractString(labels, 'k8s.namespace.name')) LIKE LOWER(?) AND labels LIKE ? AND LOWER(labels) LIKE LOWER(?)", + expectedArgs: []any{"_mango%", "%k8s.namespace.name%", `%k8s.namespace.name%_mango%%`}, + }, + { + name: "string_not_like", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorNotLike, + value: "_mango%", + expected: "LOWER(simpleJSONExtractString(labels, 'k8s.namespace.name')) NOT LIKE LOWER(?) AND LOWER(labels) NOT LIKE LOWER(?)", + expectedArgs: []any{"_mango%", `%k8s.namespace.name%_mango%%`}, + }, + { + name: "string_contains", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorContains, + value: "banana", + expected: "LOWER(simpleJSONExtractString(labels, 'k8s.namespace.name')) LIKE LOWER(?) AND labels LIKE ? AND LOWER(labels) LIKE LOWER(?)", + expectedArgs: []any{"%banana%", "%k8s.namespace.name%", `%k8s.namespace.name%banana%`}, + }, + { + name: "string_not_contains", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorNotContains, + value: "banana", + expected: "LOWER(simpleJSONExtractString(labels, 'k8s.namespace.name')) NOT LIKE LOWER(?) AND LOWER(labels) NOT LIKE LOWER(?)", + expectedArgs: []any{"%banana%", `%k8s.namespace.name%banana%`}, + }, + { + name: "string_in", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorIn, + value: []any{"watch", "redis"}, + expected: "(simpleJSONExtractString(labels, 'k8s.namespace.name') = ? OR simpleJSONExtractString(labels, 'k8s.namespace.name') = ?) AND labels LIKE ? AND (labels LIKE ? OR labels LIKE ?)", + expectedArgs: []any{"watch", "redis", "%k8s.namespace.name%", "%k8s.namespace.name%watch%", "%k8s.namespace.name%redis%"}, + }, + { + name: "string_not_in", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorNotIn, + value: []any{"watch", "redis"}, + expected: "(simpleJSONExtractString(labels, 'k8s.namespace.name') <> ? AND simpleJSONExtractString(labels, 'k8s.namespace.name') <> ?) AND (labels NOT LIKE ? AND labels NOT LIKE ?)", + expectedArgs: []any{"watch", "redis", "%k8s.namespace.name%watch%", "%k8s.namespace.name%redis%"}, + }, + { + name: "string_exists", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorExists, + expected: "simpleJSONHas(labels, 'k8s.namespace.name') = ? AND labels LIKE ?", + expectedArgs: []any{true, "%k8s.namespace.name%"}, + }, + { + name: "string_not_exists", + key: &telemetrytypes.TelemetryFieldKey{ + Name: "k8s.namespace.name", + FieldContext: telemetrytypes.FieldContextResource, + }, + op: querybuildertypesv5.FilterOperatorNotExists, + expected: "simpleJSONHas(labels, 'k8s.namespace.name') <> ?", + expectedArgs: []any{true}, + }, + } + + fm := NewFieldMapper() + conditionBuilder := NewConditionBuilder(fm) + + for _, tc := range testCases { + sb := sqlbuilder.NewSelectBuilder() + t.Run(tc.name, func(t *testing.T) { + cond, err := conditionBuilder.ConditionFor(context.Background(), tc.key, tc.op, tc.value, sb) + sb.Where(cond) + + if tc.expectedErr != nil { + assert.Error(t, err) + } else { + require.NoError(t, err) + sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse) + assert.Contains(t, sql, tc.expected) + assert.Equal(t, tc.expectedArgs, args) + } + }) + } +} diff --git a/pkg/querybuilder/resourcefilter/field_mapper.go b/pkg/querybuilder/resourcefilter/field_mapper.go new file mode 100644 index 000000000000..73e0e7dd3158 --- /dev/null +++ b/pkg/querybuilder/resourcefilter/field_mapper.go @@ -0,0 +1,72 @@ +package resourcefilter + +import ( + "context" + "fmt" + + schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" +) + +var ( + resourceColumns = map[string]*schema.Column{ + "labels": {Name: "labels", Type: schema.ColumnTypeString}, + "fingerprint": {Name: "fingerprint", Type: schema.ColumnTypeString}, + "seen_at_ts_bucket_start": {Name: "seen_at_ts_bucket_start", Type: schema.ColumnTypeInt64}, + } +) + +type defaultFieldMapper struct{} + +var _ qbtypes.FieldMapper = (*defaultFieldMapper)(nil) + +func NewFieldMapper() *defaultFieldMapper { + return &defaultFieldMapper{} +} + +func (m *defaultFieldMapper) getColumn( + _ context.Context, + key *telemetrytypes.TelemetryFieldKey, +) (*schema.Column, error) { + if key.FieldContext == telemetrytypes.FieldContextResource { + return resourceColumns["labels"], nil + } + if col, ok := resourceColumns[key.Name]; ok { + return col, nil + } + return nil, qbtypes.ErrColumnNotFound +} + +func (m *defaultFieldMapper) ColumnFor( + ctx context.Context, + key *telemetrytypes.TelemetryFieldKey, +) (*schema.Column, error) { + return m.getColumn(ctx, key) +} + +func (m *defaultFieldMapper) FieldFor( + ctx context.Context, + key *telemetrytypes.TelemetryFieldKey, +) (string, error) { + column, err := m.getColumn(ctx, key) + if err != nil { + return "", err + } + if key.FieldContext == telemetrytypes.FieldContextResource { + return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", column.Name, key.Name), nil + } + return column.Name, nil +} + +func (m *defaultFieldMapper) ColumnExpressionFor( + ctx context.Context, + key *telemetrytypes.TelemetryFieldKey, + _ map[string][]*telemetrytypes.TelemetryFieldKey, +) (string, error) { + colName, err := m.FieldFor(ctx, key) + if err != nil { + return "", err + } + return fmt.Sprintf("%s AS `%s`", colName, key.Name), nil +} diff --git a/pkg/querybuilder/resourcefilter/filter_compiler.go b/pkg/querybuilder/resourcefilter/filter_compiler.go new file mode 100644 index 000000000000..bf75b24bf020 --- /dev/null +++ b/pkg/querybuilder/resourcefilter/filter_compiler.go @@ -0,0 +1,47 @@ +package resourcefilter + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/querybuilder" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +type FilterCompilerOpts struct { + FieldMapper qbtypes.FieldMapper + ConditionBuilder qbtypes.ConditionBuilder + MetadataStore telemetrytypes.MetadataStore +} + +type filterCompiler struct { + opts FilterCompilerOpts +} + +func NewFilterCompiler(opts FilterCompilerOpts) *filterCompiler { + return &filterCompiler{ + opts: opts, + } +} + +func (c *filterCompiler) Compile(ctx context.Context, expr string) (*sqlbuilder.WhereClause, []string, error) { + selectors := querybuilder.QueryStringToKeysSelectors(expr) + + keys, err := c.opts.MetadataStore.GetKeysMulti(ctx, selectors) + if err != nil { + return nil, nil, err + } + + filterWhereClause, warnings, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{ + FieldMapper: c.opts.FieldMapper, + ConditionBuilder: c.opts.ConditionBuilder, + FieldKeys: keys, + }) + + if err != nil { + return nil, nil, err + } + + return filterWhereClause, warnings, nil +} diff --git a/pkg/querybuilder/resourcefilter/statement_builder.go b/pkg/querybuilder/resourcefilter/statement_builder.go new file mode 100644 index 000000000000..f4afcb2e6e07 --- /dev/null +++ b/pkg/querybuilder/resourcefilter/statement_builder.go @@ -0,0 +1,133 @@ +package resourcefilter + +import ( + "context" + "fmt" + + "github.com/SigNoz/signoz/pkg/errors" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +type ResourceFilterStatementBuilderOpts struct { + FieldMapper qbtypes.FieldMapper + ConditionBuilder qbtypes.ConditionBuilder + Compiler qbtypes.FilterCompiler +} + +var ( + ErrUnsupportedSignal = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal type") +) + +// Configuration for different signal types +type signalConfig struct { + dbName string + tableName string +} + +var signalConfigs = map[telemetrytypes.Signal]signalConfig{ + telemetrytypes.SignalTraces: { + dbName: TracesDBName, + tableName: TraceResourceV3TableName, + }, + telemetrytypes.SignalLogs: { + dbName: LogsDBName, + tableName: LogsResourceV2TableName, + }, +} + +// Generic resource filter statement builder +type resourceFilterStatementBuilder[T any] struct { + opts ResourceFilterStatementBuilderOpts + signal telemetrytypes.Signal +} + +// Ensure interface compliance at compile time +var ( + _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*resourceFilterStatementBuilder[qbtypes.TraceAggregation])(nil) + _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*resourceFilterStatementBuilder[qbtypes.LogAggregation])(nil) +) + +// Constructor functions +func NewTraceResourceFilterStatementBuilder(opts ResourceFilterStatementBuilderOpts) *resourceFilterStatementBuilder[qbtypes.TraceAggregation] { + return &resourceFilterStatementBuilder[qbtypes.TraceAggregation]{ + opts: opts, + signal: telemetrytypes.SignalTraces, + } +} + +func NewLogResourceFilterStatementBuilder(opts ResourceFilterStatementBuilderOpts) *resourceFilterStatementBuilder[qbtypes.LogAggregation] { + return &resourceFilterStatementBuilder[qbtypes.LogAggregation]{ + opts: opts, + signal: telemetrytypes.SignalLogs, + } +} + +// Build builds a SQL query based on the given parameters +func (b *resourceFilterStatementBuilder[T]) Build( + ctx context.Context, + start uint64, + end uint64, + requestType qbtypes.RequestType, + query qbtypes.QueryBuilderQuery[T], +) (*qbtypes.Statement, error) { + config, exists := signalConfigs[b.signal] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrUnsupportedSignal, b.signal) + } + + q := sqlbuilder.ClickHouse.NewSelectBuilder() + q.Select("fingerprint") + q.From(fmt.Sprintf("%s.%s", config.dbName, config.tableName)) + + if err := b.addConditions(ctx, q, start, end, query); err != nil { + return nil, err + } + + stmt, args := q.Build() + return &qbtypes.Statement{ + Query: stmt, + Args: args, + }, nil +} + +// addConditions adds both filter and time conditions to the query +func (b *resourceFilterStatementBuilder[T]) addConditions( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + start, end uint64, + query qbtypes.QueryBuilderQuery[T], +) error { + // Add filter condition if present + if query.Filter != nil && query.Filter.Expression != "" { + filterWhereClause, _, err := b.opts.Compiler.Compile(ctx, query.Filter.Expression) + if err != nil { + return err + } + if filterWhereClause != nil { + sb.AddWhereClause(filterWhereClause) + } + } + + // Add time filter + b.addTimeFilter(sb, start, end) + return nil +} + +// addTimeFilter adds time-based filtering conditions +func (b *resourceFilterStatementBuilder[T]) addTimeFilter(sb *sqlbuilder.SelectBuilder, start, end uint64) { + // Convert nanoseconds to seconds and adjust start bucket + const ( + nsToSeconds = 1000000000 + bucketAdjustment = 1800 // 30 minutes + ) + + startBucket := start/nsToSeconds - bucketAdjustment + endBucket := end / nsToSeconds + + sb.Where( + sb.GE("seen_at_ts_bucket_start", startBucket), + sb.LE("seen_at_ts_bucket_start", endBucket), + ) +} diff --git a/pkg/querybuilder/resourcefilter/tables.go b/pkg/querybuilder/resourcefilter/tables.go new file mode 100644 index 000000000000..bcc8133341ee --- /dev/null +++ b/pkg/querybuilder/resourcefilter/tables.go @@ -0,0 +1,8 @@ +package resourcefilter + +const ( + TracesDBName = "signoz_traces" + TraceResourceV3TableName = "distributed_traces_v3_resource" + LogsDBName = "signoz_logs" + LogsResourceV2TableName = "distributed_logs_v2_resource" +) diff --git a/pkg/querybuilder/time.go b/pkg/querybuilder/time.go new file mode 100644 index 000000000000..4c9fe46f0466 --- /dev/null +++ b/pkg/querybuilder/time.go @@ -0,0 +1,18 @@ +package querybuilder + +import "math" + +// ToNanoSecs takes epoch and returns it in ns +func ToNanoSecs(epoch uint64) uint64 { + temp := epoch + count := 0 + if epoch == 0 { + count = 1 + } else { + for epoch != 0 { + epoch /= 10 + count++ + } + } + return temp * uint64(math.Pow(10, float64(19-count))) +} diff --git a/pkg/querybuilder/time_test.go b/pkg/querybuilder/time_test.go new file mode 100644 index 000000000000..7617d93fa478 --- /dev/null +++ b/pkg/querybuilder/time_test.go @@ -0,0 +1,62 @@ +package querybuilder + +import "testing" + +func TestToNanoSecs(t *testing.T) { + tests := []struct { + name string + epoch uint64 + expected uint64 + }{ + { + name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC", + epoch: 1672531200, // January 1, 2023 00:00:00 UTC + expected: 1672531200000000000, // 1672531200 * 10^9 + }, + { + name: "13-digit Unix timestamp (milliseconds) - 2023-01-01 00:00:00 UTC", + epoch: 1672531200000, // January 1, 2023 00:00:00.000 UTC + expected: 1672531200000000000, // 1672531200000 * 10^6 + }, + { + name: "16-digit Unix timestamp (microseconds) - 2023-01-01 00:00:00 UTC", + epoch: 1672531200000000, // January 1, 2023 00:00:00.000000 UTC + expected: 1672531200000000000, // 1672531200000000 * 10^3 + }, + { + name: "19-digit Unix timestamp (nanoseconds) - 2023-01-01 00:00:00 UTC", + epoch: 1672531200000000000, // January 1, 2023 00:00:00.000000000 UTC + expected: 1672531200000000000, // 1672531200000000000 * 10^0 + }, + { + name: "Unix epoch start - 1970-01-01 00:00:00 UTC", + epoch: 0, + expected: 0, + }, + { + name: "Recent timestamp - 2024-05-25 12:00:00 UTC", + epoch: 1716638400, // May 25, 2024 12:00:00 UTC + expected: 1716638400000000000, // 1716638400 * 10^9 + }, + + { + name: "Large valid timestamp - 2025-05-15 10:30:45 UTC", + epoch: 1747204245, // May 15, 2025 10:30:45 UTC + expected: 1747204245000000000, // 1747204245 * 10^9 + }, + { + name: "18-digit microsecond timestamp", + epoch: 1672531200123456, // Jan 1, 2023 with microseconds + expected: 1672531200123456000, // 1672531200123456 * 10^3 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToNanoSecs(tt.epoch) + if result != tt.expected { + t.Errorf("ToNanoSecs(%d) = %d, want %d", tt.epoch, result, tt.expected) + } + }) + } +} diff --git a/pkg/telemetrytraces/filter_compiler.go b/pkg/telemetrytraces/filter_compiler.go new file mode 100644 index 000000000000..a91e2312647d --- /dev/null +++ b/pkg/telemetrytraces/filter_compiler.go @@ -0,0 +1,55 @@ +package telemetrytraces + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/querybuilder" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +type FilterCompilerOpts struct { + FieldMapper qbtypes.FieldMapper + ConditionBuilder qbtypes.ConditionBuilder + MetadataStore telemetrytypes.MetadataStore + FullTextColumn *telemetrytypes.TelemetryFieldKey + JsonBodyPrefix string + JsonKeyToKey qbtypes.JsonKeyToFieldFunc + SkipResourceFilter bool +} + +type filterCompiler struct { + opts FilterCompilerOpts +} + +func NewFilterCompiler(opts FilterCompilerOpts) *filterCompiler { + return &filterCompiler{ + opts: opts, + } +} + +func (c *filterCompiler) Compile(ctx context.Context, expr string) (*sqlbuilder.WhereClause, []string, error) { + selectors := querybuilder.QueryStringToKeysSelectors(expr) + + keys, err := c.opts.MetadataStore.GetKeysMulti(ctx, selectors) + if err != nil { + return nil, nil, err + } + + filterWhereClause, warnings, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{ + FieldMapper: c.opts.FieldMapper, + ConditionBuilder: c.opts.ConditionBuilder, + FieldKeys: keys, + FullTextColumn: c.opts.FullTextColumn, + JsonBodyPrefix: c.opts.JsonBodyPrefix, + JsonKeyToKey: c.opts.JsonKeyToKey, + SkipResourceFilter: c.opts.SkipResourceFilter, + }) + + if err != nil { + return nil, nil, err + } + + return filterWhereClause, warnings, nil +} diff --git a/pkg/telemetrytraces/statement_builder.go b/pkg/telemetrytraces/statement_builder.go new file mode 100644 index 000000000000..c6313f820c1c --- /dev/null +++ b/pkg/telemetrytraces/statement_builder.go @@ -0,0 +1,456 @@ +package telemetrytraces + +import ( + "context" + "fmt" + "strings" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/querybuilder" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +var ( + ErrUnsupportedAggregation = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported aggregation") +) + +type TraceQueryStatementBuilderOpts struct { + MetadataStore telemetrytypes.MetadataStore + FieldMapper qbtypes.FieldMapper + ConditionBuilder qbtypes.ConditionBuilder + ResourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation] + Compiler qbtypes.FilterCompiler + AggExprRewriter qbtypes.AggExprRewriter +} + +type traceQueryStatementBuilder struct { + opts TraceQueryStatementBuilderOpts + fm qbtypes.FieldMapper + cb qbtypes.ConditionBuilder + compiler qbtypes.FilterCompiler + aggExprRewriter qbtypes.AggExprRewriter +} + +var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil) + +func NewTraceQueryStatementBuilder(opts TraceQueryStatementBuilderOpts) *traceQueryStatementBuilder { + return &traceQueryStatementBuilder{ + opts: opts, + fm: opts.FieldMapper, + cb: opts.ConditionBuilder, + compiler: opts.Compiler, + aggExprRewriter: opts.AggExprRewriter, + } +} + +// Build builds a SQL query for traces based on the given parameters +func (b *traceQueryStatementBuilder) Build( + ctx context.Context, + start uint64, + end uint64, + requestType qbtypes.RequestType, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], +) (*qbtypes.Statement, error) { + + start = querybuilder.ToNanoSecs(start) + end = querybuilder.ToNanoSecs(end) + + keySelectors := getKeySelectors(query) + keys, err := b.opts.MetadataStore.GetKeysMulti(ctx, keySelectors) + if err != nil { + return nil, err + } + + // Create SQL builder + q := sqlbuilder.ClickHouse.NewSelectBuilder() + + switch requestType { + case qbtypes.RequestTypeRaw: + return b.buildListQuery(ctx, q, query, start, end, keys) + case qbtypes.RequestTypeTimeSeries: + return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys) + case qbtypes.RequestTypeScalar: + return b.buildScalarQuery(ctx, q, query, start, end, keys, false) + } + + return nil, fmt.Errorf("unsupported request type: %s", requestType) +} + +func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) []*telemetrytypes.FieldKeySelector { + var keySelectors []*telemetrytypes.FieldKeySelector + + for idx := range query.Aggregations { + aggExpr := query.Aggregations[idx] + selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression) + keySelectors = append(keySelectors, selectors...) + } + + whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) + keySelectors = append(keySelectors, whereClauseSelectors...) + + return keySelectors +} + +// buildListQuery builds a query for list panel type +func (b *traceQueryStatementBuilder) buildListQuery( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], + start, end uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, +) (*qbtypes.Statement, error) { + + var ( + cteFragments []string + cteArgs [][]any + ) + + if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end); err != nil { + return nil, err + } else if frag != "" { + cteFragments = append(cteFragments, frag) + cteArgs = append(cteArgs, args) + } + + // Select default columns + sb.Select( + "timestamp", + "trace_id", + "span_id", + "name", + "resource_string_service$$name", + "duration_nano", + "response_status_code", + ) + + for _, field := range query.SelectFields { + colExpr, err := b.fm.ColumnExpressionFor(ctx, &field, keys) + if err != nil { + return nil, err + } + sb.SelectMore(colExpr) + } + + // From table + sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName)) + + // Add filter conditions + warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + if err != nil { + return nil, err + } + + // Add order by + for _, orderBy := range query.Order { + sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction)) + } + + // Add limit and offset + if query.Limit > 0 { + sb.Limit(query.Limit) + } + + if query.Offset > 0 { + sb.Offset(query.Offset) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse) + + finalSQL := combineCTEs(cteFragments) + mainSQL + finalArgs := prependArgs(cteArgs, mainArgs) + + return &qbtypes.Statement{ + Query: finalSQL, + Args: finalArgs, + Warnings: warnings, + }, nil +} + +func (b *traceQueryStatementBuilder) buildTimeSeriesQuery( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], + start, end uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, +) (*qbtypes.Statement, error) { + + var ( + cteFragments []string + cteArgs [][]any + ) + + if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end); err != nil { + return nil, err + } else if frag != "" { + cteFragments = append(cteFragments, frag) + cteArgs = append(cteArgs, args) + } + + sb.SelectMore(fmt.Sprintf( + "toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts", + int64(query.StepInterval.Seconds()), + )) + + // Keep original column expressions so we can build the tuple + fieldNames := make([]string, 0, len(query.GroupBy)) + for _, gb := range query.GroupBy { + colExpr, err := b.fm.ColumnExpressionFor(ctx, &gb.TelemetryFieldKey, keys) + if err != nil { + return nil, err + } + sb.SelectMore(colExpr) + fieldNames = append(fieldNames, fmt.Sprintf("`%s`", gb.TelemetryFieldKey.Name)) + } + + // Aggregations + allAggChArgs := make([]any, 0) + for i, agg := range query.Aggregations { + rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, agg.Expression) + if err != nil { + return nil, err + } + allAggChArgs = append(allAggChArgs, chArgs...) + sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i)) + } + + sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName)) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + if err != nil { + return nil, err + } + + var finalSQL string + var finalArgs []any + + if query.Limit > 0 { + // build the scalar “top/bottom-N” query in its own builder. + cteSB := sqlbuilder.ClickHouse.NewSelectBuilder() + cteStmt, err := b.buildScalarQuery(ctx, cteSB, query, start, end, keys, true) + if err != nil { + return nil, err + } + + cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", cteStmt.Query)) + cteArgs = append(cteArgs, cteStmt.Args) + + // Constrain the main query to the rows that appear in the CTE. + tuple := fmt.Sprintf("(%s)", strings.Join(fieldNames, ", ")) + sb.Where(fmt.Sprintf("%s IN (SELECT %s FROM __limit_cte)", tuple, strings.Join(fieldNames, ", "))) + + // Group by all dimensions + sb.GroupBy("ALL") + if query.Having != nil && query.Having.Expression != "" { + sb.Having(query.Having.Expression) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + + // Stitch it all together: WITH … SELECT … + finalSQL = combineCTEs(cteFragments) + mainSQL + finalArgs = prependArgs(cteArgs, mainArgs) + + } else { + sb.GroupBy("ALL") + if query.Having != nil && query.Having.Expression != "" { + sb.Having(query.Having.Expression) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + + // Stitch it all together: WITH … SELECT … + finalSQL = combineCTEs(cteFragments) + mainSQL + finalArgs = prependArgs(cteArgs, mainArgs) + } + + return &qbtypes.Statement{ + Query: finalSQL, + Args: finalArgs, + Warnings: warnings, + }, nil +} + +// buildScalarQuery builds a query for scalar panel type +func (b *traceQueryStatementBuilder) buildScalarQuery( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], + start, end uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, + skipResourceCTE bool, +) (*qbtypes.Statement, error) { + + var ( + cteFragments []string + cteArgs [][]any + ) + + if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end); err != nil { + return nil, err + } else if frag != "" && !skipResourceCTE { + cteFragments = append(cteFragments, frag) + cteArgs = append(cteArgs, args) + } + + allAggChArgs := []any{} + + // Add group by columns + for _, groupBy := range query.GroupBy { + colExpr, err := b.fm.ColumnExpressionFor(ctx, &groupBy.TelemetryFieldKey, keys) + if err != nil { + return nil, err + } + sb.SelectMore(colExpr) + } + + // Add aggregation + if len(query.Aggregations) > 0 { + for idx := range query.Aggregations { + aggExpr := query.Aggregations[idx] + rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, aggExpr.Expression) + if err != nil { + return nil, err + } + allAggChArgs = append(allAggChArgs, chArgs...) + sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx)) + } + } + + // From table + sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName)) + + // Add filter conditions + warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + if err != nil { + return nil, err + } + + // Group by dimensions + sb.GroupBy("ALL") + + // Add having clause if needed + if query.Having != nil && query.Having.Expression != "" { + sb.Having(query.Having.Expression) + } + + // Add order by + for _, orderBy := range query.Order { + idx, ok := aggOrderBy(orderBy, query) + if ok { + sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction)) + } else { + sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction)) + } + } + + // if there is no order by, then use the __result_0 as the order by + if len(query.Order) == 0 { + sb.OrderBy("__result_0 DESC") + } + + // Add limit and offset + if query.Limit > 0 { + sb.Limit(query.Limit) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + + finalSQL := combineCTEs(cteFragments) + mainSQL + finalArgs := prependArgs(cteArgs, mainArgs) + + return &qbtypes.Statement{ + Query: finalSQL, + Args: finalArgs, + Warnings: warnings, + }, nil +} + +// buildFilterCondition builds SQL condition from filter expression +func (b *traceQueryStatementBuilder) addFilterCondition(ctx context.Context, sb *sqlbuilder.SelectBuilder, start, end uint64, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) ([]string, error) { + + // add filter expression + + filterWhereClause, warnings, err := b.compiler.Compile(ctx, query.Filter.Expression) + + if err != nil { + return nil, err + } + + if filterWhereClause != nil { + sb.AddWhereClause(filterWhereClause) + } + + // add time filter + startBucket := start/1000000000 - 1800 + endBucket := end / 1000000000 + + sb.Where(sb.GE("timestamp", start), sb.LE("timestamp", end), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket)) + + return warnings, nil +} + +// combineCTEs takes any number of individual CTE fragments like +// +// "__resource_filter AS (...)", "__limit_cte AS (...)" +// +// and renders the final `WITH …` clause. +func combineCTEs(ctes []string) string { + if len(ctes) == 0 { + return "" + } + return "WITH " + strings.Join(ctes, ", ") + " " +} + +// prependArgs ensures CTE arguments appear before main-query arguments +// in the final slice so their ordinal positions match the SQL string. +func prependArgs(cteArgs [][]any, mainArgs []any) []any { + out := make([]any, 0, len(mainArgs)+len(cteArgs)) + for _, a := range cteArgs { // CTEs first, in declaration order + out = append(out, a...) + } + return append(out, mainArgs...) +} + +func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) { + for i, agg := range q.Aggregations { + if k.Key.Name == agg.Alias || + k.Key.Name == agg.Expression || + k.Key.Name == fmt.Sprintf("%d", i) { + return i, true + } + } + return 0, false +} + +func (b *traceQueryStatementBuilder) maybeAttachResourceFilter( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], + start, end uint64, +) (cteSQL string, cteArgs []any, err error) { + + stmt, err := b.buildResourceFilterCTE(ctx, query, start, end) + if err != nil { + return "", nil, err + } + + sb.Where("resource_fingerprint IN (SELECT fingerprint FROM __resource_filter)") + + return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil +} + +func (b *traceQueryStatementBuilder) buildResourceFilterCTE( + ctx context.Context, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], + start, end uint64, +) (*qbtypes.Statement, error) { + + return b.opts.ResourceFilterStmtBuilder.Build( + ctx, + start, + end, + qbtypes.RequestTypeRaw, + query, + ) +} diff --git a/pkg/telemetrytraces/stmt_builder_test.go b/pkg/telemetrytraces/stmt_builder_test.go new file mode 100644 index 000000000000..864838cf301a --- /dev/null +++ b/pkg/telemetrytraces/stmt_builder_test.go @@ -0,0 +1,117 @@ +package telemetrytraces + +import ( + "context" + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/querybuilder" + "github.com/SigNoz/signoz/pkg/querybuilder/resourcefilter" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest" + "github.com/stretchr/testify/require" +) + +func resourceFilterStmtBuilder() (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) { + fm := resourcefilter.NewFieldMapper() + cb := resourcefilter.NewConditionBuilder(fm) + mockMetadataStore := telemetrytypestest.NewMockMetadataStore() + mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() + compiler := resourcefilter.NewFilterCompiler(resourcefilter.FilterCompilerOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + MetadataStore: mockMetadataStore, + }) + + return resourcefilter.NewTraceResourceFilterStatementBuilder(resourcefilter.ResourceFilterStatementBuilderOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + Compiler: compiler, + }), nil +} + +func TestStatementBuilder(t *testing.T) { + cases := []struct { + name string + requestType qbtypes.RequestType + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] + expected qbtypes.Statement + expectedErr error + }{ + { + name: "test", + requestType: qbtypes.RequestTypeTimeSeries, + query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{ + Signal: telemetrytypes.SignalTraces, + StepInterval: qbtypes.Step{Duration: 30 * time.Second}, + Aggregations: []qbtypes.TraceAggregation{ + { + Expression: "count()", + }, + }, + Filter: &qbtypes.Filter{ + Expression: "service.name = 'redis-manual'", + }, + Limit: 10, + GroupBy: []qbtypes.GroupByKey{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: "service.name", + }, + }, + }, + }, + expected: qbtypes.Statement{ + Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ALL ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) IN (SELECT `service.name` FROM __limit_cte) GROUP BY ALL", + Args: []any{"redis-manual", "%service.name%", "%service.name%redis-manual%", uint64(1747945619), uint64(1747983448), uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448), 10, uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448)}, + }, + expectedErr: nil, + }, + } + + fm := NewFieldMapper() + cb := NewConditionBuilder(fm) + mockMetadataStore := telemetrytypestest.NewMockMetadataStore() + mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() + compiler := NewFilterCompiler(FilterCompilerOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + MetadataStore: mockMetadataStore, + SkipResourceFilter: true, + }) + aggExprRewriter := querybuilder.NewAggExprRewriter(querybuilder.AggExprRewriterOptions{ + FieldMapper: fm, + ConditionBuilder: cb, + MetadataStore: mockMetadataStore, + }) + + resourceFilterStmtBuilder, err := resourceFilterStmtBuilder() + require.NoError(t, err) + + statementBuilder := NewTraceQueryStatementBuilder(TraceQueryStatementBuilderOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + Compiler: compiler, + MetadataStore: mockMetadataStore, + AggExprRewriter: aggExprRewriter, + ResourceFilterStmtBuilder: resourceFilterStmtBuilder, + }) + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + + q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query) + + if c.expectedErr != nil { + require.Error(t, err) + require.Contains(t, err.Error(), c.expectedErr.Error()) + } else { + require.NoError(t, err) + require.Equal(t, c.expected.Query, q.Query) + require.Equal(t, c.expected.Args, q.Args) + require.Equal(t, c.expected.Warnings, q.Warnings) + } + }) + } +} diff --git a/pkg/telemetrytraces/test_data.go b/pkg/telemetrytraces/test_data.go new file mode 100644 index 000000000000..051af18cc712 --- /dev/null +++ b/pkg/telemetrytraces/test_data.go @@ -0,0 +1,38 @@ +package telemetrytraces + +import ( + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" +) + +func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey { + return map[string][]*telemetrytypes.TelemetryFieldKey{ + "service.name": { + { + Name: "service.name", + FieldContext: telemetrytypes.FieldContextResource, + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + }, + "http.request.method": { + { + Name: "http.request.method", + FieldContext: telemetrytypes.FieldContextAttribute, + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + }, + "http.response.status_code": { + { + Name: "http.status_code", + FieldContext: telemetrytypes.FieldContextAttribute, + FieldDataType: telemetrytypes.FieldDataTypeInt64, + }, + }, + "kind_string": { + { + Name: "kind_string", + FieldContext: telemetrytypes.FieldContextSpan, + FieldDataType: telemetrytypes.FieldDataTypeString, + }, + }, + } +} From 3ca3db256737f14dc4114b620d063596715ec073 Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Mon, 26 May 2025 12:45:21 +0700 Subject: [PATCH 07/24] chore: add to alerts/dashboard improvements for one chart per query mode in metrics explorer (#8014) --- .../ExplorerOptions/ExplorerOptionWrapper.tsx | 4 + .../ExplorerOptions.styles.scss | 15 ++ .../ExplorerOptions/ExplorerOptions.tsx | 237 ++++++++++++++---- .../MetricsExplorer/Explorer/Explorer.tsx | 22 +- 4 files changed, 220 insertions(+), 58 deletions(-) diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx index a2e0eff9c829..beef5c66861d 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptionWrapper.tsx @@ -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} /> ); } diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss index 9efc053245bd..179003da452b 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss @@ -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 { diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index 3090babe1d91..fa4a723f1747 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -90,6 +90,8 @@ function ExplorerOptions({ sourcepage, isExplorerOptionHidden = false, setIsExplorerOptionHidden, + isOneChartPerQuery = false, + splitedQueries = [], }: ExplorerOptionsProps): JSX.Element { const [isExport, setIsExport] = useState(false); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -99,6 +101,8 @@ function ExplorerOptions({ const history = useHistory(); const ref = useRef(null); const isDarkMode = useIsDarkMode(); + const [queryToExport, setQueryToExport] = useState(null); + const isLogsExplorer = sourcepage === DataSource.LOGS; const isMetricsExplorer = sourcepage === DataSource.METRICS; @@ -149,51 +153,62 @@ function ExplorerOptions({ const { user } = useAppContext(); - const handleConditionalQueryModification = useCallback((): string => { - if ( - query?.builder?.queryData?.[0]?.aggregateOperator !== StringOperators.NOOP - ) { - return JSON.stringify(query); - } + const handleConditionalQueryModification = useCallback( + (defaultQuery: Query | null): string => { + const queryToUse = defaultQuery || query; + if ( + queryToUse?.builder?.queryData?.[0]?.aggregateOperator !== + StringOperators.NOOP + ) { + return JSON.stringify(queryToUse); + } - // Modify aggregateOperator to count, as noop is not supported in alerts - const modifiedQuery = cloneDeep(query); + // Modify aggregateOperator to count, as noop is not supported in alerts + const modifiedQuery = cloneDeep(queryToUse); - modifiedQuery.builder.queryData[0].aggregateOperator = StringOperators.COUNT; + modifiedQuery.builder.queryData[0].aggregateOperator = StringOperators.COUNT; - return JSON.stringify(modifiedQuery); - }, [query]); + return JSON.stringify(modifiedQuery); + }, + [query], + ); - const onCreateAlertsHandler = useCallback(() => { - if (sourcepage === DataSource.TRACES) { - logEvent('Traces Explorer: Create alert', { - panelType, - }); - } else if (isLogsExplorer) { - logEvent('Logs Explorer: Create alert', { - panelType, - }); - } else if (isMetricsExplorer) { - logEvent('Metrics Explorer: Create alert', { - panelType, - }); - } + const onCreateAlertsHandler = useCallback( + (defaultQuery: Query | null) => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Create alert', { + panelType, + }); + } else if (isLogsExplorer) { + logEvent('Logs Explorer: Create alert', { + panelType, + }); + } else if (isMetricsExplorer) { + logEvent('Metrics Explorer: Create alert', { + panelType, + }); + } - const stringifiedQuery = handleConditionalQueryModification(); + const stringifiedQuery = handleConditionalQueryModification(defaultQuery); - history.push( - `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent( - stringifiedQuery, - )}`, - ); + 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 = ( + + ); + return ( + + ); + } + return ( + + ); + }, [ + disabled, + isOneChartPerQuery, + onCreateAlertsHandler, + query, + splitedQueries, + ]); + + const dashboardButton = useMemo(() => { + if (isOneChartPerQuery) { + const selectLabel = ( + + ); + return ( + + ); + } + return ( + + ); + }, [disabled, isOneChartPerQuery, onAddToDashboard, splitedQueries]); + return (
{ @@ -719,24 +848,8 @@ function ExplorerOptions({
- - - + {alertButton} + {dashboardButton}
{/* Hide the info icon for metrics explorer until we get the docs link */} @@ -818,9 +931,15 @@ function ExplorerOptions({ destroyOnClose > { + if (isOneChartPerQuery && queryToExport) { + onExport(dashboard, isNewDashboard, queryToExport); + } else { + onExport(dashboard, isNewDashboard); + } + }} />
@@ -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>; + isOneChartPerQuery?: boolean; + splitedQueries?: Query[]; } ExplorerOptions.defaultProps = { isLoading: false, isExplorerOptionHidden: false, setIsExplorerOptionHidden: undefined, + isOneChartPerQuery: false, + splitedQueries: [], }; export default ExplorerOptions; diff --git a/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx b/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx index eae66505c586..1aed2e567bcb 100644 --- a/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx +++ b/frontend/src/container/MetricsExplorer/Explorer/Explorer.tsx @@ -19,6 +19,7 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall import { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { Dashboard } from 'types/api/dashboard/getAll'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; import { v4 as uuid } from 'uuid'; @@ -26,6 +27,7 @@ import { v4 as uuid } from 'uuid'; import QuerySection from './QuerySection'; import TimeSeries from './TimeSeries'; import { ExplorerTabs } from './types'; +import { splitQueryIntoOneChartPerQuery } from './utils'; const ONE_CHART_PER_QUERY_ENABLED_KEY = 'isOneChartPerQueryEnabled'; @@ -75,14 +77,18 @@ function Explorer(): JSX.Element { useShareBuilderUrl(exportDefaultQuery); const handleExport = useCallback( - (dashboard: Dashboard | null): void => { + ( + dashboard: Dashboard | null, + _isNewDashboard?: boolean, + queryToExport?: Query, + ): void => { if (!dashboard) return; const widgetId = uuid(); const updatedDashboard = addEmptyWidgetInDashboardJSONWithQuery( dashboard, - exportDefaultQuery, + queryToExport || exportDefaultQuery, widgetId, PANEL_TYPES.TIME_SERIES, options.selectColumns, @@ -114,7 +120,7 @@ function Explorer(): JSX.Element { return; } const dashboardEditView = generateExportToDashboardLink({ - query: exportDefaultQuery, + query: queryToExport || exportDefaultQuery, panelType: PANEL_TYPES.TIME_SERIES, dashboardId: data.payload?.uuid || '', widgetId, @@ -135,6 +141,14 @@ function Explorer(): JSX.Element { [exportDefaultQuery, notifications, updateDashboard], ); + const splitedQueries = useMemo( + () => + splitQueryIntoOneChartPerQuery( + stagedQuery || initialQueriesMap[DataSource.METRICS], + ), + [stagedQuery], + ); + return ( }>
@@ -190,6 +204,8 @@ function Explorer(): JSX.Element { isLoading={isLoading} sourcepage={DataSource.METRICS} onExport={handleExport} + isOneChartPerQuery={showOneChartPerQuery} + splitedQueries={splitedQueries} /> ); From cdbf23d0533a337cd153c626f1f480d0a288678d Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Mon, 26 May 2025 13:03:01 +0700 Subject: [PATCH 08/24] chore: infra monitoring improvements (#8002) --- .../HostMetricsLogs/HostMetricsLogs.tsx | 1 - .../HostMetricsDetail/Metrics/Metrics.tsx | 3 +- .../LogDetail/LogDetails.styles.scss | 19 +++++++ frontend/src/components/LogDetail/index.tsx | 52 +++++++++++++++++-- .../EntityLogs/EntityLogs.tsx | 1 - .../EntityMetrics/EntityMetrics.tsx | 3 +- .../src/lib/uPlotLib/getUplotChartOptions.ts | 20 +++++++ 7 files changed, 90 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx index 8279336eea07..95e535dad77b 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx +++ b/frontend/src/components/HostMetricsDetail/HostMetricsLogs/HostMetricsLogs.tsx @@ -75,7 +75,6 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element { const getItemContent = useCallback( (_: number, logToRender: ILog): JSX.Element => (
( + (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={ - <> - - Log details - +
+
+ + Log details +
+ {showOpenInExplorerBtn && ( +
+ +
+ )} +
} placement="right" // closable diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogs.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogs.tsx index 919559c795ad..1bb45b251f41 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogs.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogs.tsx @@ -65,7 +65,6 @@ function EntityLogs({ const getItemContent = useCallback( (_: number, logToRender: ILog): JSX.Element => ( ({ softMin: null, minTimeScale: timeRange.startTime, maxTimeScale: timeRange.endTime, + enableZoom: true, }); }), [ @@ -162,7 +163,7 @@ function EntityMetrics({
uPlot.Series[]; isLogScale?: boolean; colorMapping?: Record; + enableZoom?: boolean; } /** the function converts series A , series B , series C to @@ -168,6 +169,7 @@ export const getUPlotChartOptions = ({ customSeries, isLogScale, colorMapping, + enableZoom, }: GetUPlotChartOptions): uPlot.Options => { const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale); @@ -205,7 +207,25 @@ export const getUPlotChartOptions = ({ `${u.series[seriesIdx].points.stroke(u, seriesIdx)}90`, fill: (): string => '#fff', }, + ...(enableZoom + ? { + drag: { + x: true, + y: true, + }, + focus: { + prox: 30, + }, + } + : {}), }, + ...(enableZoom + ? { + select: { + show: true, + }, + } + : {}), tzDate, padding: [16, 16, 8, 8], bands, From 650cf8132914d7ec768d4dfe18c8f69b3ee3f570 Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Mon, 26 May 2025 13:43:06 +0700 Subject: [PATCH 09/24] chore: metrics explorer minor fixes (#8042) --- .../MetricsExplorer/MetricDetails/MetricDetails.tsx | 2 +- .../container/MetricsExplorer/Summary/MetricsTreemap.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx index 97670b50618c..b0c18ab64b47 100644 --- a/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx +++ b/frontend/src/container/MetricsExplorer/MetricDetails/MetricDetails.tsx @@ -75,7 +75,7 @@ function MetricDetails({ hour." placement="top" > - {`${timeSeriesTotal} ⎯ ${timeSeriesActive} active`} + {`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`} ); }, [metric]); diff --git a/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx b/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx index f9f81fa92c13..ef9649be1dc6 100644 --- a/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/MetricsTreemap.tsx @@ -154,10 +154,14 @@ function MetricsTreemap({ openMetricDetails(node.data.id)} > -
+
{`${node.data.displayValue}%`}
From 3a396602a8f9a0cbefa7f3c7cfcca8980bb4c8d8 Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Mon, 26 May 2025 13:58:55 +0700 Subject: [PATCH 10/24] chore: persist the state selection in the URL for all entities and filters in Infra Monitoring (#7991) --- .../HostMetricsDetail/HostMetricsDetails.tsx | 10 +- .../InfraMonitoringHosts/HostsList.tsx | 60 ++++++++-- .../HostsListControls.tsx | 5 +- .../InfraMonitoringHosts/HostsListTable.tsx | 4 +- .../container/InfraMonitoringHosts/utils.tsx | 11 +- .../ClusterDetails/ClusterDetails.tsx | 103 +++++++++++++---- .../Clusters/K8sClustersList.tsx | 76 +++++++++++-- .../DaemonSetDetails/DaemonSetDetails.tsx | 102 ++++++++++++++--- .../DaemonSets/K8sDaemonSetsList.tsx | 82 ++++++++++++-- .../DeploymentDetails/DeploymentDetails.tsx | 107 ++++++++++++++---- .../Deployments/K8sDeploymentsList.tsx | 76 +++++++++++-- .../EntityEvents/EntityEvents.tsx | 17 ++- .../EntityLogs/EntityLogsDetailedView.tsx | 5 +- .../EntityTraces/EntityTraces.tsx | 10 +- .../InfraMonitoringK8s/InfraMonitoringK8s.tsx | 20 +++- .../Jobs/JobDetails/JobDetails.tsx | 99 +++++++++++++--- .../InfraMonitoringK8s/Jobs/K8sJobsList.tsx | 74 ++++++++++-- .../InfraMonitoringK8s/K8sHeader.tsx | 27 +++-- .../Namespaces/K8sNamespacesList.tsx | 76 +++++++++++-- .../NamespaceDetails/NamespaceDetails.tsx | 100 +++++++++++++--- .../InfraMonitoringK8s/Nodes/K8sNodesList.tsx | 78 +++++++++++-- .../Nodes/NodeDetails/NodeDetails.tsx | 103 +++++++++++++---- .../InfraMonitoringK8s/Pods/K8sPodLists.tsx | 74 ++++++++++-- .../Pods/PodDetails/PodDetails.tsx | 103 +++++++++++++---- .../StatefulSets/K8sStatefulSetsList.tsx | 76 +++++++++++-- .../StatefulSetDetails/StatefulSetDetails.tsx | 106 +++++++++++++---- .../Volumes/K8sVolumesList.tsx | 69 +++++++++-- .../InfraMonitoringK8s/commonUtils.tsx | 45 +++++++- .../container/InfraMonitoringK8s/constants.ts | 21 ++++ 29 files changed, 1474 insertions(+), 265 deletions(-) diff --git a/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx b/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx index 2af30b994396..fa21c837298d 100644 --- a/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx +++ b/frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx @@ -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.METRICS); + const [selectedView, setSelectedView] = useState( + (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); diff --git a/frontend/src/container/InfraMonitoringHosts/HostsList.tsx b/frontend/src/container/InfraMonitoringHosts/HostsList.tsx index c06f63a4b067..f4dac3f9eb16 100644 --- a/frontend/src/container/InfraMonitoringHosts/HostsList.tsx +++ b/frontend/src/container/InfraMonitoringHosts/HostsList.tsx @@ -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( (state) => state.globalTime, ); + const [searchParams, setSearchParams] = useSearchParams(); const [currentPage, setCurrentPage] = useState(1); - const [filters, setFilters] = useState({ - items: [], - op: 'and', + const [filters, setFilters] = useState(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS, + ); + if (!filters) { + return { + items: [], + op: 'and', + }; + } + return filters; }); const [showFilters, setShowFilters] = useState(true); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams)); - const [selectedHostName, setSelectedHostName] = useState(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(() => { + 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 {
)} - +
diff --git a/frontend/src/container/InfraMonitoringHosts/HostsListControls.tsx b/frontend/src/container/InfraMonitoringHosts/HostsListControls.tsx index 5c7cad87ffe2..ca0e85e75226 100644 --- a/frontend/src/container/InfraMonitoringHosts/HostsListControls.tsx +++ b/frontend/src/container/InfraMonitoringHosts/HostsListControls.tsx @@ -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; diff --git a/frontend/src/container/InfraMonitoringHosts/HostsListTable.tsx b/frontend/src/container/InfraMonitoringHosts/HostsListTable.tsx index ba9b35143bfa..5c7d3bbe17c7 100644 --- a/frontend/src/container/InfraMonitoringHosts/HostsListTable.tsx +++ b/frontend/src/container/InfraMonitoringHosts/HostsListTable.tsx @@ -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, diff --git a/frontend/src/container/InfraMonitoringHosts/utils.tsx b/frontend/src/container/InfraMonitoringHosts/utils.tsx index 743a9135b0df..b6778ff22d6a 100644 --- a/frontend/src/container/InfraMonitoringHosts/utils.tsx +++ b/frontend/src/container/InfraMonitoringHosts/utils.tsx @@ -41,16 +41,13 @@ export interface HostsListTableProps { | undefined; hostMetricsData: HostData[]; filters: TagFilter; - setSelectedHostName: Dispatch>; + onHostClick: (hostName: string) => void; currentPage: number; setCurrentPage: Dispatch>; pageSize: number; - setOrderBy: Dispatch< - SetStateAction<{ - columnName: string; - order: 'asc' | 'desc'; - } | null> - >; + setOrderBy: ( + orderBy: { columnName: string; order: 'asc' | 'desc' } | null, + ) => void; setPageSize: (pageSize: number) => void; } diff --git a/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx index b6b22f462ddd..b0712c94e4d6 100644 --- a/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails.tsx @@ -14,8 +14,14 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { + filterDuplicateFilters, + getFiltersFromParams, +} from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/utils'; import { CustomTimeType, @@ -34,6 +40,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 { @@ -82,11 +89,27 @@ function ClusterDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -103,12 +126,18 @@ function ClusterDetails({ value: cluster?.meta.k8s_cluster_name || '', }, ], - }), - [cluster?.meta.k8s_cluster_name], - ); + }; + }, [cluster?.meta.k8s_cluster_name, searchParams]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -138,9 +167,8 @@ function ClusterDetails({ value: cluster?.meta.k8s_cluster_name || '', }, ], - }), - [cluster?.meta.k8s_cluster_name], - ); + }; + }, [cluster?.meta.k8s_cluster_name, searchParams]); const [logsAndTracesFilters, setLogsAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -181,6 +209,13 @@ function ClusterDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -220,7 +255,7 @@ function ClusterDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogsAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_CLUSTER_NAME].includes(item.key?.key ?? ''), @@ -240,7 +275,7 @@ function ClusterDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -250,6 +285,16 @@ function ClusterDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -257,7 +302,7 @@ function ClusterDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogsAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_CLUSTER_NAME].includes(item.key?.key ?? ''), @@ -272,7 +317,7 @@ function ClusterDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -283,6 +328,16 @@ function ClusterDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -290,7 +345,7 @@ function ClusterDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const clusterKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -308,7 +363,7 @@ function ClusterDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -322,6 +377,16 @@ function ClusterDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/Clusters/K8sClustersList.tsx b/frontend/src/container/InfraMonitoringK8s/Clusters/K8sClustersList.tsx index 19e920ffe999..6732d018dc40 100644 --- a/frontend/src/container/InfraMonitoringK8s/Clusters/K8sClustersList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Clusters/K8sClustersList.tsx @@ -23,11 +23,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -59,19 +62,36 @@ function K8sClustersList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>({ columnName: 'cpu', order: 'desc' }); + } | null>(() => getOrderByFromParams(searchParams, false)); const [selectedClusterName, setselectedClusterName] = useState( - null, + () => { + const clusterName = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.CLUSTER_NAME, + ); + if (clusterName) { + return clusterName; + } + return null; + }, ); const { pageSize, setPageSize } = usePageSize(K8sCategory.CLUSTERS); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [ selectedRowData, @@ -258,15 +278,26 @@ function K8sClustersList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -322,6 +353,10 @@ function K8sClustersList({ if (groupBy.length === 0) { setSelectedRowData(null); setselectedClusterName(record.clusterUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.CLUSTER_NAME]: record.clusterUID, + }); } else { handleGroupByRowClick(record); } @@ -348,6 +383,11 @@ function K8sClustersList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -372,7 +412,9 @@ function K8sClustersList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedClusterName(record.clusterUID), + onClick: (): void => { + setselectedClusterName(record.clusterUID); + }, className: 'expanded-clickable-row', })} /> @@ -436,6 +478,20 @@ function K8sClustersList({ const handleCloseClusterDetail = (): void => { setselectedClusterName(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.CLUSTER_NAME, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -457,6 +513,10 @@ function K8sClustersList({ // Reset pagination on switching to groupBy setCurrentPage(1); setGroupBy(groupBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); setExpandedRowKeys([]); logEvent(InfraMonitoringEvents.GroupByChanged, { entity: InfraMonitoringEvents.K8sEntity, @@ -464,7 +524,7 @@ function K8sClustersList({ category: InfraMonitoringEvents.Cluster, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails.tsx b/frontend/src/container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails.tsx index 58ef36949af7..7b347835249a 100644 --- a/frontend/src/container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails.tsx @@ -13,7 +13,11 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import { CustomTimeType, Time, @@ -31,6 +35,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 { @@ -83,11 +88,27 @@ function DaemonSetDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -117,12 +138,22 @@ function DaemonSetDetails({ value: daemonSet?.meta.k8s_namespace_name || '', }, ], - }), - [daemonSet?.meta.k8s_daemonset_name, daemonSet?.meta.k8s_namespace_name], - ); + }; + }, [ + daemonSet?.meta.k8s_daemonset_name, + daemonSet?.meta.k8s_namespace_name, + searchParams, + ]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -152,9 +183,8 @@ function DaemonSetDetails({ value: daemonSet?.meta.k8s_daemonset_name || '', }, ], - }), - [daemonSet?.meta.k8s_daemonset_name], - ); + }; + }, [daemonSet?.meta.k8s_daemonset_name, searchParams]); const [logAndTracesFilters, setLogAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -195,6 +225,13 @@ function DaemonSetDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -234,7 +271,7 @@ function DaemonSetDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_DAEMON_SET_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -257,7 +294,7 @@ function DaemonSetDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -265,6 +302,15 @@ function DaemonSetDetails({ ...(paginationFilter ? [paginationFilter] : []), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -272,7 +318,7 @@ function DaemonSetDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_DAEMON_SET_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -289,7 +335,7 @@ function DaemonSetDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -298,6 +344,16 @@ function DaemonSetDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -305,7 +361,7 @@ function DaemonSetDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const daemonSetKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -323,7 +379,7 @@ function DaemonSetDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ daemonSetKindFilter, @@ -335,6 +391,16 @@ function DaemonSetDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/DaemonSets/K8sDaemonSetsList.tsx b/frontend/src/container/InfraMonitoringK8s/DaemonSets/K8sDaemonSetsList.tsx index 8518a470c33f..a104144e52fc 100644 --- a/frontend/src/container/InfraMonitoringK8s/DaemonSets/K8sDaemonSetsList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/DaemonSets/K8sDaemonSetsList.tsx @@ -24,11 +24,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -59,21 +62,38 @@ function K8sDaemonSetsList({ ); const [currentPage, setCurrentPage] = useState(1); + const [searchParams, setSearchParams] = useSearchParams(); const [expandedRowKeys, setExpandedRowKeys] = useState([]); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams, true)); - const [selectedDaemonSetUID, setselectedDaemonSetUID] = useState< + const [selectedDaemonSetUID, setSelectedDaemonSetUID] = useState< string | null - >(null); + >(() => { + const daemonSetUID = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.DAEMONSET_UID, + ); + if (daemonSetUID) { + return daemonSetUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.DAEMONSETS); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [ selectedRowData, @@ -262,15 +282,26 @@ function K8sDaemonSetsList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -329,7 +360,11 @@ function K8sDaemonSetsList({ const handleRowClick = (record: K8sDaemonSetsRowData): void => { if (groupBy.length === 0) { setSelectedRowData(null); - setselectedDaemonSetUID(record.daemonsetUID); + setSelectedDaemonSetUID(record.daemonsetUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.DAEMONSET_UID]: record.daemonsetUID, + }); } else { handleGroupByRowClick(record); } @@ -356,6 +391,11 @@ function K8sDaemonSetsList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -380,7 +420,9 @@ function K8sDaemonSetsList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedDaemonSetUID(record.daemonsetUID), + onClick: (): void => { + setSelectedDaemonSetUID(record.daemonsetUID); + }, className: 'expanded-clickable-row', })} /> @@ -443,7 +485,21 @@ function K8sDaemonSetsList({ }; const handleCloseDaemonSetDetail = (): void => { - setselectedDaemonSetUID(null); + setSelectedDaemonSetUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.DAEMONSET_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -464,6 +520,10 @@ function K8sDaemonSetsList({ setCurrentPage(1); setGroupBy(groupBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); setExpandedRowKeys([]); logEvent(InfraMonitoringEvents.GroupByChanged, { @@ -472,7 +532,7 @@ function K8sDaemonSetsList({ category: InfraMonitoringEvents.DaemonSet, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/Deployments/DeploymentDetails/DeploymentDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Deployments/DeploymentDetails/DeploymentDetails.tsx index 2531f4723398..951b4cf7dd89 100644 --- a/frontend/src/container/InfraMonitoringK8s/Deployments/DeploymentDetails/DeploymentDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Deployments/DeploymentDetails/DeploymentDetails.tsx @@ -14,8 +14,14 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { + filterDuplicateFilters, + getFiltersFromParams, +} from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/utils'; import { CustomTimeType, @@ -34,6 +40,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 { @@ -85,11 +92,27 @@ function DeploymentDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -119,12 +142,22 @@ function DeploymentDetails({ value: deployment?.meta.k8s_namespace_name || '', }, ], - }), - [deployment?.meta.k8s_deployment_name, deployment?.meta.k8s_namespace_name], - ); + }; + }, [ + deployment?.meta.k8s_deployment_name, + deployment?.meta.k8s_namespace_name, + searchParams, + ]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -154,9 +187,8 @@ function DeploymentDetails({ value: deployment?.meta.k8s_deployment_name || '', }, ], - }), - [deployment?.meta.k8s_deployment_name], - ); + }; + }, [deployment?.meta.k8s_deployment_name, searchParams]); const [logAndTracesFilters, setLogAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -197,6 +229,13 @@ function DeploymentDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -236,7 +275,7 @@ function DeploymentDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_DEPLOYMENT_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -259,7 +298,7 @@ function DeploymentDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -269,6 +308,16 @@ function DeploymentDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -276,7 +325,7 @@ function DeploymentDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_DEPLOYMENT_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -293,7 +342,7 @@ function DeploymentDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -304,6 +353,16 @@ function DeploymentDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -311,7 +370,7 @@ function DeploymentDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const deploymentKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -329,7 +388,7 @@ function DeploymentDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -343,6 +402,16 @@ function DeploymentDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/Deployments/K8sDeploymentsList.tsx b/frontend/src/container/InfraMonitoringK8s/Deployments/K8sDeploymentsList.tsx index 7b463bf4af8c..b4294226bcc4 100644 --- a/frontend/src/container/InfraMonitoringK8s/Deployments/K8sDeploymentsList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Deployments/K8sDeploymentsList.tsx @@ -24,11 +24,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -61,19 +64,36 @@ function K8sDeploymentsList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams, true)); const [selectedDeploymentUID, setselectedDeploymentUID] = useState< string | null - >(null); + >(() => { + const deploymentUID = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.DEPLOYMENT_UID, + ); + if (deploymentUID) { + return deploymentUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.DEPLOYMENTS); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [ selectedRowData, @@ -264,15 +284,26 @@ function K8sDeploymentsList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -333,6 +364,10 @@ function K8sDeploymentsList({ if (groupBy.length === 0) { setSelectedRowData(null); setselectedDeploymentUID(record.deploymentUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.DEPLOYMENT_UID]: record.deploymentUID, + }); } else { handleGroupByRowClick(record); } @@ -359,6 +394,11 @@ function K8sDeploymentsList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -383,7 +423,9 @@ function K8sDeploymentsList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedDeploymentUID(record.deploymentUID), + onClick: (): void => { + setselectedDeploymentUID(record.deploymentUID); + }, className: 'expanded-clickable-row', })} /> @@ -447,6 +489,20 @@ function K8sDeploymentsList({ const handleCloseDeploymentDetail = (): void => { setselectedDeploymentUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.DEPLOYMENT_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -468,6 +524,10 @@ function K8sDeploymentsList({ // Reset pagination on switching to groupBy setCurrentPage(1); setGroupBy(groupBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); setExpandedRowKeys([]); logEvent(InfraMonitoringEvents.GroupByChanged, { @@ -476,7 +536,7 @@ function K8sDeploymentsList({ category: InfraMonitoringEvents.Deployment, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/EntityEvents.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/EntityEvents.tsx index 70e02902c995..97808f2d299b 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/EntityEvents.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents/EntityEvents.tsx @@ -3,6 +3,7 @@ import './entityEvents.styles.scss'; import { Color } from '@signozhq/design-tokens'; import { Button, Table, TableColumnsType } from 'antd'; +import { VIEWS } from 'components/HostMetricsDetail/constants'; import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import { EventContents } from 'container/InfraMonitoringK8s/commonUtils'; import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; @@ -28,6 +29,7 @@ import { DataSource } from 'types/common/queryBuilder'; import { EntityDetailsEmptyContainer, getEntityEventsOrLogsQueryPayload, + QUERY_KEYS, } from '../utils'; interface EventDataType { @@ -55,7 +57,10 @@ interface IEntityEventsProps { startTime: number; endTime: number; }; - handleChangeEventFilters: (filters: IBuilderQuery['filters']) => void; + handleChangeEventFilters: ( + filters: IBuilderQuery['filters'], + view: VIEWS, + ) => void; filters: IBuilderQuery['filters']; isModalTimeSelection: boolean; handleTimeChange: ( @@ -103,14 +108,18 @@ export default function Events({ ...currentQuery.builder.queryData[0].aggregateAttribute, }, filters: { - items: [], + items: filters.items.filter( + (item) => + item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND && + item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME, + ), op: 'AND', }, }, ], }, }), - [currentQuery], + [currentQuery, filters], ); const query = updatedCurrentQuery?.builder?.queryData[0] || null; @@ -243,7 +252,7 @@ export default function Events({ {query && ( handleChangeEventFilters(value, VIEWS.EVENTS)} disableNavigationShortcuts /> )} diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogsDetailedView.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogsDetailedView.tsx index e2f071956d76..7b235edfbde7 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogsDetailedView.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs/EntityLogsDetailedView.tsx @@ -1,5 +1,6 @@ import './entityLogs.styles.scss'; +import { VIEWS } from 'components/HostMetricsDetail/constants'; import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; @@ -25,7 +26,7 @@ interface Props { interval: Time | CustomTimeType, dateTimeRange?: [number, number], ) => void; - handleChangeLogFilters: (value: IBuilderQuery['filters']) => void; + handleChangeLogFilters: (value: IBuilderQuery['filters'], view: VIEWS) => void; logFilters: IBuilderQuery['filters']; selectedInterval: Time; queryKey: string; @@ -78,7 +79,7 @@ function EntityLogsDetailedView({ {query && ( handleChangeLogFilters(value, VIEWS.LOGS)} disableNavigationShortcuts /> )} diff --git a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/EntityTraces.tsx b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/EntityTraces.tsx index 8a7b0f229ab9..925fa0e54508 100644 --- a/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/EntityTraces.tsx +++ b/frontend/src/container/InfraMonitoringK8s/EntityDetailsUtils/EntityTraces/EntityTraces.tsx @@ -1,6 +1,7 @@ import './entityTraces.styles.scss'; import logEvent from 'api/common/logEvent'; +import { VIEWS } from 'components/HostMetricsDetail/constants'; import { getListColumns } from 'components/HostMetricsDetail/HostMetricTraces/utils'; import { ResizeTable } from 'components/ResizeTable'; import { DEFAULT_ENTITY_VERSION } from 'constants/app'; @@ -43,7 +44,10 @@ interface Props { interval: Time | CustomTimeType, dateTimeRange?: [number, number], ) => void; - handleChangeTracesFilters: (value: IBuilderQuery['filters']) => void; + handleChangeTracesFilters: ( + value: IBuilderQuery['filters'], + view: VIEWS, + ) => void; tracesFilters: IBuilderQuery['filters']; selectedInterval: Time; queryKey: string; @@ -164,7 +168,9 @@ function EntityTraces({ {query && ( + handleChangeTracesFilters(value, VIEWS.TRACES) + } disableNavigationShortcuts /> )} diff --git a/frontend/src/container/InfraMonitoringK8s/InfraMonitoringK8s.tsx b/frontend/src/container/InfraMonitoringK8s/InfraMonitoringK8s.tsx index a3f520629db1..9aa6ab6f33aa 100644 --- a/frontend/src/container/InfraMonitoringK8s/InfraMonitoringK8s.tsx +++ b/frontend/src/container/InfraMonitoringK8s/InfraMonitoringK8s.tsx @@ -23,6 +23,7 @@ import { } from 'lucide-react'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import K8sClustersList from './Clusters/K8sClustersList'; @@ -30,6 +31,7 @@ import { ClustersQuickFiltersConfig, DaemonSetsQuickFiltersConfig, DeploymentsQuickFiltersConfig, + INFRA_MONITORING_K8S_PARAMS_KEYS, JobsQuickFiltersConfig, K8sCategories, NamespaceQuickFiltersConfig, @@ -50,7 +52,14 @@ import K8sVolumesList from './Volumes/K8sVolumesList'; export default function InfraMonitoringK8s(): JSX.Element { const [showFilters, setShowFilters] = useState(true); - const [selectedCategory, setSelectedCategory] = useState(K8sCategories.PODS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedCategory, setSelectedCategory] = useState(() => { + const category = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY); + if (category) { + return category as keyof typeof K8sCategories; + } + return K8sCategories.PODS; + }); const [quickFiltersLastUpdated, setQuickFiltersLastUpdated] = useState(-1); const { currentQuery } = useQueryBuilder(); @@ -70,6 +79,12 @@ export default function InfraMonitoringK8s(): JSX.Element { // in infra monitoring k8s, we are using only one query, hence updating the 0th index of queryData handleChangeQueryData('filters', query.builder.queryData[0].filters); setQuickFiltersLastUpdated(Date.now()); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS]: JSON.stringify( + query.builder.queryData[0].filters, + ), + }); logEvent(InfraMonitoringEvents.FilterApplied, { entity: InfraMonitoringEvents.K8sEntity, @@ -295,6 +310,9 @@ export default function InfraMonitoringK8s(): JSX.Element { const handleCategoryChange = (key: string | string[]): void => { if (Array.isArray(key) && key.length > 0) { setSelectedCategory(key[0] as string); + setSearchParams({ + [INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY]: key[0] as string, + }); // Reset filters handleChangeQueryData('filters', { items: [], op: 'and' }); } diff --git a/frontend/src/container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails.tsx index 63ea8eaae90f..47063f487db1 100644 --- a/frontend/src/container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails.tsx @@ -13,7 +13,11 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import { CustomTimeType, Time, @@ -31,6 +35,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 { @@ -80,11 +85,27 @@ function JobDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -114,12 +135,18 @@ function JobDetails({ value: job?.meta.k8s_namespace_name || '', }, ], - }), - [job?.meta.k8s_job_name, job?.meta.k8s_namespace_name], - ); + }; + }, [job?.meta.k8s_job_name, job?.meta.k8s_namespace_name, searchParams]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -149,9 +176,8 @@ function JobDetails({ value: job?.meta.k8s_job_name || '', }, ], - }), - [job?.meta.k8s_job_name], - ); + }; + }, [job?.meta.k8s_job_name, searchParams]); const [logAndTracesFilters, setLogAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -192,6 +218,13 @@ function JobDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -231,7 +264,7 @@ function JobDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_JOB_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -253,7 +286,7 @@ function JobDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -261,6 +294,16 @@ function JobDetails({ ...(paginationFilter ? [paginationFilter] : []), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -268,7 +311,7 @@ function JobDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_JOB_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -285,7 +328,7 @@ function JobDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -294,6 +337,16 @@ function JobDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -301,7 +354,7 @@ function JobDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const jobKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -319,7 +372,7 @@ function JobDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ jobKindFilter, @@ -331,6 +384,16 @@ function JobDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/Jobs/K8sJobsList.tsx b/frontend/src/container/InfraMonitoringK8s/Jobs/K8sJobsList.tsx index 397d9b04889b..85d0505ce298 100644 --- a/frontend/src/container/InfraMonitoringK8s/Jobs/K8sJobsList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Jobs/K8sJobsList.tsx @@ -24,11 +24,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -61,17 +64,32 @@ function K8sJobsList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams, true)); - const [selectedJobUID, setselectedJobUID] = useState(null); + const [selectedJobUID, setselectedJobUID] = useState(() => { + const jobUID = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.JOB_UID); + if (jobUID) { + return jobUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.JOBS); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [selectedRowData, setSelectedRowData] = useState( null, @@ -251,15 +269,26 @@ function K8sJobsList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -306,6 +335,10 @@ function K8sJobsList({ if (groupBy.length === 0) { setSelectedRowData(null); setselectedJobUID(record.jobUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.JOB_UID]: record.jobUID, + }); } else { handleGroupByRowClick(record); } @@ -332,6 +365,11 @@ function K8sJobsList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -356,7 +394,9 @@ function K8sJobsList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedJobUID(record.jobUID), + onClick: (): void => { + setselectedJobUID(record.jobUID); + }, className: 'expanded-clickable-row', })} /> @@ -420,6 +460,20 @@ function K8sJobsList({ const handleCloseJobDetail = (): void => { setselectedJobUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.JOB_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -441,6 +495,10 @@ function K8sJobsList({ setCurrentPage(1); setGroupBy(groupBy); setExpandedRowKeys([]); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); logEvent(InfraMonitoringEvents.GroupByChanged, { entity: InfraMonitoringEvents.K8sEntity, @@ -448,7 +506,7 @@ function K8sJobsList({ category: InfraMonitoringEvents.Job, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/K8sHeader.tsx b/frontend/src/container/InfraMonitoringK8s/K8sHeader.tsx index 2f583a965195..fa9eaf2d6f95 100644 --- a/frontend/src/container/InfraMonitoringK8s/K8sHeader.tsx +++ b/frontend/src/container/InfraMonitoringK8s/K8sHeader.tsx @@ -7,11 +7,12 @@ import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearc import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; import { Filter, SlidersHorizontal } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom-v5-compat'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; -import { K8sCategory } from './constants'; +import { INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory } from './constants'; import K8sFiltersSidePanel from './K8sFiltersSidePanel/K8sFiltersSidePanel'; import { IEntityColumn } from './utils'; @@ -47,11 +48,19 @@ function K8sHeader({ entity, }: K8sHeaderProps): JSX.Element { const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); const currentQuery = initialQueriesMap[DataSource.METRICS]; - const updatedCurrentQuery = useMemo( - () => ({ + const updatedCurrentQuery = useMemo(() => { + const urlFilters = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS); + let { filters } = currentQuery.builder.queryData[0]; + if (urlFilters) { + const decoded = decodeURIComponent(urlFilters); + const parsed = JSON.parse(decoded); + filters = parsed; + } + return { ...currentQuery, builder: { ...currentQuery.builder, @@ -62,20 +71,24 @@ function K8sHeader({ aggregateAttribute: { ...currentQuery.builder.queryData[0].aggregateAttribute, }, + filters, }, ], }, - }), - [currentQuery], - ); + }; + }, [currentQuery, searchParams]); const query = updatedCurrentQuery?.builder?.queryData[0] || null; const handleChangeTagFilters = useCallback( (value: IBuilderQuery['filters']) => { handleFiltersChange(value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.FILTERS]: JSON.stringify(value), + }); }, - [handleFiltersChange], + [handleFiltersChange, searchParams, setSearchParams], ); return ( diff --git a/frontend/src/container/InfraMonitoringK8s/Namespaces/K8sNamespacesList.tsx b/frontend/src/container/InfraMonitoringK8s/Namespaces/K8sNamespacesList.tsx index 5181b3ac80c8..6f076132cfac 100644 --- a/frontend/src/container/InfraMonitoringK8s/Namespaces/K8sNamespacesList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Namespaces/K8sNamespacesList.tsx @@ -23,11 +23,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -60,19 +63,36 @@ function K8sNamespacesList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams, true)); const [selectedNamespaceUID, setselectedNamespaceUID] = useState< string | null - >(null); + >(() => { + const namespaceUID = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.NAMESPACE_UID, + ); + if (namespaceUID) { + return namespaceUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.NAMESPACES); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [ selectedRowData, @@ -261,15 +281,26 @@ function K8sNamespacesList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -330,6 +361,10 @@ function K8sNamespacesList({ if (groupBy.length === 0) { setSelectedRowData(null); setselectedNamespaceUID(record.namespaceUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.NAMESPACE_UID]: record.namespaceUID, + }); } else { handleGroupByRowClick(record); } @@ -356,6 +391,11 @@ function K8sNamespacesList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -380,7 +420,9 @@ function K8sNamespacesList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedNamespaceUID(record.namespaceUID), + onClick: (): void => { + setselectedNamespaceUID(record.namespaceUID); + }, className: 'expanded-clickable-row', })} /> @@ -444,6 +486,20 @@ function K8sNamespacesList({ const handleCloseNamespaceDetail = (): void => { setselectedNamespaceUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.NAMESPACE_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -466,6 +522,10 @@ function K8sNamespacesList({ setCurrentPage(1); setGroupBy(groupBy); setExpandedRowKeys([]); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); logEvent(InfraMonitoringEvents.GroupByChanged, { entity: InfraMonitoringEvents.K8sEntity, @@ -473,7 +533,7 @@ function K8sNamespacesList({ category: InfraMonitoringEvents.Namespace, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/Namespaces/NamespaceDetails/NamespaceDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Namespaces/NamespaceDetails/NamespaceDetails.tsx index f9f1801f936f..f64057e4ce41 100644 --- a/frontend/src/container/InfraMonitoringK8s/Namespaces/NamespaceDetails/NamespaceDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Namespaces/NamespaceDetails/NamespaceDetails.tsx @@ -14,7 +14,11 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/utils'; import { CustomTimeType, @@ -33,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 { @@ -84,11 +89,27 @@ function NamespaceDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -105,12 +126,18 @@ function NamespaceDetails({ value: namespace?.namespaceName || '', }, ], - }), - [namespace?.namespaceName], - ); + }; + }, [namespace?.namespaceName, searchParams]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -140,9 +167,8 @@ function NamespaceDetails({ value: namespace?.namespaceName || '', }, ], - }), - [namespace?.namespaceName], - ); + }; + }, [namespace?.namespaceName, searchParams]); const [logAndTracesFilters, setLogAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -183,6 +209,13 @@ function NamespaceDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -222,7 +255,7 @@ function NamespaceDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_NAMESPACE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME].includes( @@ -244,7 +277,7 @@ function NamespaceDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -252,6 +285,17 @@ function NamespaceDetails({ ...(paginationFilter ? [paginationFilter] : []), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -259,7 +303,7 @@ function NamespaceDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_NAMESPACE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME].includes( @@ -276,7 +320,7 @@ function NamespaceDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -285,6 +329,16 @@ function NamespaceDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -292,7 +346,7 @@ function NamespaceDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const namespaceKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -310,7 +364,7 @@ function NamespaceDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ namespaceKindFilter, @@ -322,6 +376,16 @@ function NamespaceDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/Nodes/K8sNodesList.tsx b/frontend/src/container/InfraMonitoringK8s/Nodes/K8sNodesList.tsx index d27f5d9196b9..441346980fb1 100644 --- a/frontend/src/container/InfraMonitoringK8s/Nodes/K8sNodesList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Nodes/K8sNodesList.tsx @@ -23,11 +23,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -60,17 +63,32 @@ function K8sNodesList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>({ columnName: 'cpu', order: 'desc' }); + } | null>(() => getOrderByFromParams(searchParams, false)); - const [selectedNodeUID, setselectedNodeUID] = useState(null); + const [selectedNodeUID, setSelectedNodeUID] = useState(() => { + const nodeUID = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_UID); + if (nodeUID) { + return nodeUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.NODES); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [selectedRowData, setSelectedRowData] = useState( null, @@ -250,15 +268,26 @@ function K8sNodesList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -307,7 +336,11 @@ function K8sNodesList({ const handleRowClick = (record: K8sNodesRowData): void => { if (groupBy.length === 0) { setSelectedRowData(null); - setselectedNodeUID(record.nodeUID); + setSelectedNodeUID(record.nodeUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_UID]: record.nodeUID, + }); } else { handleGroupByRowClick(record); } @@ -334,6 +367,11 @@ function K8sNodesList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -359,7 +397,9 @@ function K8sNodesList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedNodeUID(record.nodeUID), + onClick: (): void => { + setSelectedNodeUID(record.nodeUID); + }, className: 'expanded-clickable-row', })} /> @@ -422,7 +462,21 @@ function K8sNodesList({ }; const handleCloseNodeDetail = (): void => { - setselectedNodeUID(null); + setSelectedNodeUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -444,6 +498,10 @@ function K8sNodesList({ setCurrentPage(1); setGroupBy(groupBy); setExpandedRowKeys([]); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); logEvent(InfraMonitoringEvents.GroupByChanged, { entity: InfraMonitoringEvents.K8sEntity, @@ -451,7 +509,7 @@ function K8sNodesList({ category: InfraMonitoringEvents.Node, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails.tsx index a1d54e4a781c..a216ea010cbe 100644 --- a/frontend/src/container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails.tsx @@ -14,8 +14,14 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { + filterDuplicateFilters, + getFiltersFromParams, +} from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import NodeEvents from 'container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents'; import { CustomTimeType, @@ -34,6 +40,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 { @@ -82,11 +89,27 @@ function NodeDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -103,12 +126,18 @@ function NodeDetails({ value: node?.meta.k8s_node_name || '', }, ], - }), - [node?.meta.k8s_node_name], - ); + }; + }, [node?.meta.k8s_node_name, searchParams]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -138,9 +167,8 @@ function NodeDetails({ value: node?.meta.k8s_node_name || '', }, ], - }), - [node?.meta.k8s_node_name], - ); + }; + }, [node?.meta.k8s_node_name, searchParams]); const [logAndTracesFilters, setLogAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -181,6 +209,13 @@ function NodeDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -220,7 +255,7 @@ function NodeDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_NODE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME].includes( @@ -242,7 +277,7 @@ function NodeDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -252,6 +287,16 @@ function NodeDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -259,7 +304,7 @@ function NodeDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_NODE_NAME, QUERY_KEYS.K8S_CLUSTER_NAME].includes( @@ -276,7 +321,7 @@ function NodeDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -287,6 +332,16 @@ function NodeDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -294,7 +349,7 @@ function NodeDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const nodeKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -312,7 +367,7 @@ function NodeDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ nodeKindFilter, @@ -324,6 +379,16 @@ function NodeDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/Pods/K8sPodLists.tsx b/frontend/src/container/InfraMonitoringK8s/Pods/K8sPodLists.tsx index 08d8a2ddc4c5..f6bc79171e1f 100644 --- a/frontend/src/container/InfraMonitoringK8s/Pods/K8sPodLists.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Pods/K8sPodLists.tsx @@ -24,11 +24,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight, CornerDownRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -59,6 +62,7 @@ function K8sPodsList({ const { maxTime, minTime } = useSelector( (state) => state.globalTime, ); + const [searchParams, setSearchParams] = useSearchParams(); const [currentPage, setCurrentPage] = useState(1); @@ -68,7 +72,15 @@ function K8sPodsList({ defaultAvailableColumns, ); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [selectedRowData, setSelectedRowData] = useState( null, @@ -134,9 +146,15 @@ function K8sPodsList({ const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>({ columnName: 'cpu', order: 'desc' }); + } | null>(() => getOrderByFromParams(searchParams, false)); - const [selectedPodUID, setSelectedPodUID] = useState(null); + const [selectedPodUID, setSelectedPodUID] = useState(() => { + const podUID = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.POD_UID); + if (podUID) { + return podUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.PODS); @@ -265,15 +283,26 @@ function K8sPodsList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -318,6 +347,10 @@ function K8sPodsList({ setCurrentPage(1); setGroupBy(groupBy); setExpandedRowKeys([]); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); logEvent(InfraMonitoringEvents.GroupByChanged, { entity: InfraMonitoringEvents.K8sEntity, @@ -325,7 +358,7 @@ function K8sPodsList({ category: InfraMonitoringEvents.Pod, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { @@ -366,6 +399,10 @@ function K8sPodsList({ const handleRowClick = (record: K8sPodsRowData): void => { if (groupBy.length === 0) { setSelectedPodUID(record.podUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.POD_UID]: record.podUID, + }); setSelectedRowData(null); } else { handleGroupByRowClick(record); @@ -380,6 +417,20 @@ function K8sPodsList({ const handleClosePodDetail = (): void => { setSelectedPodUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.POD_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleAddColumn = useCallback( @@ -435,6 +486,11 @@ function K8sPodsList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -459,7 +515,9 @@ function K8sPodsList({ indicator: } />, }} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setSelectedPodUID(record.podUID), + onClick: (): void => { + setSelectedPodUID(record.podUID); + }, className: 'expanded-clickable-row', })} /> diff --git a/frontend/src/container/InfraMonitoringK8s/Pods/PodDetails/PodDetails.tsx b/frontend/src/container/InfraMonitoringK8s/Pods/PodDetails/PodDetails.tsx index dec4d7d19df8..aefb5a0bb1d4 100644 --- a/frontend/src/container/InfraMonitoringK8s/Pods/PodDetails/PodDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Pods/PodDetails/PodDetails.tsx @@ -15,8 +15,14 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { filterDuplicateFilters } from 'container/InfraMonitoringK8s/commonUtils'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { + filterDuplicateFilters, + getFiltersFromParams, +} from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/utils'; import { CustomTimeType, @@ -35,6 +41,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 { @@ -86,11 +93,27 @@ function PodDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -120,12 +143,18 @@ function PodDetails({ value: pod?.meta.k8s_namespace_name || '', }, ], - }), - [pod?.meta.k8s_namespace_name, pod?.meta.k8s_pod_name], - ); + }; + }, [pod?.meta.k8s_namespace_name, pod?.meta.k8s_pod_name, searchParams]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -155,9 +184,8 @@ function PodDetails({ value: pod?.meta.k8s_pod_name || '', }, ], - }), - [pod?.meta.k8s_pod_name], - ); + }; + }, [pod?.meta.k8s_pod_name, searchParams]); const [logsAndTracesFilters, setLogsAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -198,6 +226,13 @@ function PodDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -237,7 +272,7 @@ function PodDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogsAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [ @@ -261,7 +296,7 @@ function PodDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -271,6 +306,16 @@ function PodDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -278,7 +323,7 @@ function PodDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogsAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [ @@ -297,7 +342,7 @@ function PodDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: filterDuplicateFilters( [ @@ -308,6 +353,16 @@ function PodDetails({ ].filter((item): item is TagFilterItem => item !== undefined), ), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -315,7 +370,7 @@ function PodDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const podKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -333,7 +388,7 @@ function PodDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ podKindFilter, @@ -345,6 +400,16 @@ function PodDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/StatefulSets/K8sStatefulSetsList.tsx b/frontend/src/container/InfraMonitoringK8s/StatefulSets/K8sStatefulSetsList.tsx index f3bdb46ec55c..ba2b14f8defe 100644 --- a/frontend/src/container/InfraMonitoringK8s/StatefulSets/K8sStatefulSetsList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/StatefulSets/K8sStatefulSetsList.tsx @@ -24,11 +24,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -60,19 +63,36 @@ function K8sStatefulSetsList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams, true)); const [selectedStatefulSetUID, setselectedStatefulSetUID] = useState< string | null - >(null); + >(() => { + const statefulSetUID = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.STATEFULSET_UID, + ); + if (statefulSetUID) { + return statefulSetUID; + } + return null; + }); const { pageSize, setPageSize } = usePageSize(K8sCategory.STATEFULSETS); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [ selectedRowData, @@ -263,15 +283,26 @@ function K8sStatefulSetsList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -330,6 +361,10 @@ function K8sStatefulSetsList({ if (groupBy.length === 0) { setSelectedRowData(null); setselectedStatefulSetUID(record.statefulsetUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.STATEFULSET_UID]: record.statefulsetUID, + }); } else { handleGroupByRowClick(record); } @@ -356,6 +391,11 @@ function K8sStatefulSetsList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -380,7 +420,9 @@ function K8sStatefulSetsList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedStatefulSetUID(record.statefulsetUID), + onClick: (): void => { + setselectedStatefulSetUID(record.statefulsetUID); + }, className: 'expanded-clickable-row', })} /> @@ -444,6 +486,20 @@ function K8sStatefulSetsList({ const handleCloseStatefulSetDetail = (): void => { setselectedStatefulSetUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => + ![ + INFRA_MONITORING_K8S_PARAMS_KEYS.STATEFULSET_UID, + INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW, + INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS, + ].includes(key), + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -465,6 +521,10 @@ function K8sStatefulSetsList({ setCurrentPage(1); setGroupBy(groupBy); setExpandedRowKeys([]); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); logEvent(InfraMonitoringEvents.GroupByChanged, { entity: InfraMonitoringEvents.K8sEntity, @@ -472,7 +532,7 @@ function K8sStatefulSetsList({ category: InfraMonitoringEvents.StatefulSet, }); }, - [groupByFiltersData], + [groupByFiltersData, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/StatefulSets/StatefulSetDetails/StatefulSetDetails.tsx b/frontend/src/container/InfraMonitoringK8s/StatefulSets/StatefulSetDetails/StatefulSetDetails.tsx index 4ebcd39847b7..aac8c1c60fc7 100644 --- a/frontend/src/container/InfraMonitoringK8s/StatefulSets/StatefulSetDetails/StatefulSetDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8s/StatefulSets/StatefulSetDetails/StatefulSetDetails.tsx @@ -13,7 +13,11 @@ import { initialQueryState, } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; -import { K8sCategory } from 'container/InfraMonitoringK8s/constants'; +import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils'; +import { + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from 'container/InfraMonitoringK8s/constants'; import EntityEvents from 'container/InfraMonitoringK8s/EntityDetailsUtils/EntityEvents'; import EntityLogs from 'container/InfraMonitoringK8s/EntityDetailsUtils/EntityLogs'; import EntityMetrics from 'container/InfraMonitoringK8s/EntityDetailsUtils/EntityMetrics'; @@ -36,6 +40,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 { @@ -83,11 +88,27 @@ function StatefulSetDetails({ selectedTime as Time, ); - const [selectedView, setSelectedView] = useState(VIEWS.METRICS); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedView, setSelectedView] = useState(() => { + const view = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + if (view) { + return view as VIEWS; + } + return VIEWS.METRICS; + }); const isDarkMode = useIsDarkMode(); - const initialFilters = useMemo( - () => ({ + const initialFilters = useMemo(() => { + const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW); + const queryKey = + urlView === VIEW_TYPES.LOGS + ? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS + : INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS; + const filters = getFiltersFromParams(searchParams, queryKey); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -117,15 +138,22 @@ function StatefulSetDetails({ value: statefulSet?.meta.k8s_namespace_name || '', }, ], - }), - [ - statefulSet?.meta.k8s_statefulset_name, - statefulSet?.meta.k8s_namespace_name, - ], - ); + }; + }, [ + searchParams, + statefulSet?.meta.k8s_statefulset_name, + statefulSet?.meta.k8s_namespace_name, + ]); - const initialEventsFilters = useMemo( - () => ({ + const initialEventsFilters = useMemo(() => { + const filters = getFiltersFromParams( + searchParams, + INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS, + ); + if (filters) { + return filters; + } + return { op: 'AND', items: [ { @@ -155,9 +183,8 @@ function StatefulSetDetails({ value: statefulSet?.meta.k8s_statefulset_name || '', }, ], - }), - [statefulSet?.meta.k8s_statefulset_name], - ); + }; + }, [searchParams, statefulSet?.meta.k8s_statefulset_name]); const [logAndTracesFilters, setLogAndTracesFilters] = useState< IBuilderQuery['filters'] @@ -198,6 +225,13 @@ function StatefulSetDetails({ const handleTabChange = (e: RadioChangeEvent): void => { setSelectedView(e.target.value); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value, + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify(null), + }); logEvent(InfraMonitoringEvents.TabChanged, { entity: InfraMonitoringEvents.K8sEntity, page: InfraMonitoringEvents.DetailedPage, @@ -237,7 +271,7 @@ function StatefulSetDetails({ ); const handleChangeLogFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_STATEFUL_SET_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -260,7 +294,7 @@ function StatefulSetDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -268,6 +302,16 @@ function StatefulSetDetails({ ...(paginationFilter ? [paginationFilter] : []), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -275,7 +319,7 @@ function StatefulSetDetails({ ); const handleChangeTracesFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setLogAndTracesFilters((prevFilters) => { const primaryFilters = prevFilters.items.filter((item) => [QUERY_KEYS.K8S_STATEFUL_SET_NAME, QUERY_KEYS.K8S_NAMESPACE_NAME].includes( @@ -292,7 +336,7 @@ function StatefulSetDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ ...primaryFilters, @@ -301,6 +345,16 @@ function StatefulSetDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -308,7 +362,7 @@ function StatefulSetDetails({ ); const handleChangeEventsFilters = useCallback( - (value: IBuilderQuery['filters']) => { + (value: IBuilderQuery['filters'], view: VIEWS) => { setEventsFilters((prevFilters) => { const statefulSetKindFilter = prevFilters.items.find( (item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND, @@ -326,7 +380,7 @@ function StatefulSetDetails({ }); } - return { + const updatedFilters = { op: 'AND', items: [ statefulSetKindFilter, @@ -338,6 +392,16 @@ function StatefulSetDetails({ ), ].filter((item): item is TagFilterItem => item !== undefined), }; + + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.EVENTS_FILTERS]: JSON.stringify( + updatedFilters, + ), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view, + }); + + return updatedFilters; }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/container/InfraMonitoringK8s/Volumes/K8sVolumesList.tsx b/frontend/src/container/InfraMonitoringK8s/Volumes/K8sVolumesList.tsx index 9ade0544ecdc..9ba4a7544804 100644 --- a/frontend/src/container/InfraMonitoringK8s/Volumes/K8sVolumesList.tsx +++ b/frontend/src/container/InfraMonitoringK8s/Volumes/K8sVolumesList.tsx @@ -24,11 +24,14 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations import { ChevronDown, ChevronRight } 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 } from 'types/api/queryBuilder/queryBuilderData'; import { GlobalReducer } from 'types/reducer/globalTime'; +import { getOrderByFromParams } from '../commonUtils'; import { + INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategory, K8sEntityToAggregateAttributeMapping, } from '../constants'; @@ -60,19 +63,36 @@ function K8sVolumesList({ const [currentPage, setCurrentPage] = useState(1); const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); const [orderBy, setOrderBy] = useState<{ columnName: string; order: 'asc' | 'desc'; - } | null>(null); + } | null>(() => getOrderByFromParams(searchParams, true)); const [selectedVolumeUID, setselectedVolumeUID] = useState( - null, + () => { + const volumeUID = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.VOLUME_UID, + ); + if (volumeUID) { + return volumeUID; + } + return null; + }, ); const { pageSize, setPageSize } = usePageSize(K8sCategory.VOLUMES); - const [groupBy, setGroupBy] = useState([]); + const [groupBy, setGroupBy] = useState(() => { + const groupBy = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY); + if (groupBy) { + const decoded = decodeURIComponent(groupBy); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['groupBy']; + } + return []; + }); const [ selectedRowData, @@ -253,15 +273,26 @@ function K8sVolumesList({ } if ('field' in sorter && sorter.order) { - setOrderBy({ + const currentOrderBy = { columnName: sorter.field as string, - order: sorter.order === 'ascend' ? 'asc' : 'desc', + order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc', + }; + setOrderBy(currentOrderBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify( + currentOrderBy, + ), }); } else { setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); } }, - [], + [searchParams, setSearchParams], ); const { handleChangeQueryData } = useQueryOperations({ @@ -315,6 +346,10 @@ function K8sVolumesList({ if (groupBy.length === 0) { setSelectedRowData(null); setselectedVolumeUID(record.volumeUID); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.VOLUME_UID]: record.volumeUID, + }); } else { handleGroupByRowClick(record); } @@ -341,6 +376,11 @@ function K8sVolumesList({ setSelectedRowData(null); setGroupBy([]); setOrderBy(null); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify([]), + [INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY]: JSON.stringify(null), + }); }; const expandedRowRender = (): JSX.Element => ( @@ -365,7 +405,9 @@ function K8sVolumesList({ }} showHeader={false} onRow={(record): { onClick: () => void; className: string } => ({ - onClick: (): void => setselectedVolumeUID(record.volumeUID), + onClick: (): void => { + setselectedVolumeUID(record.volumeUID); + }, className: 'expanded-clickable-row', })} /> @@ -429,6 +471,13 @@ function K8sVolumesList({ const handleCloseVolumeDetail = (): void => { setselectedVolumeUID(null); + setSearchParams({ + ...Object.fromEntries( + Array.from(searchParams.entries()).filter( + ([key]) => key !== INFRA_MONITORING_K8S_PARAMS_KEYS.VOLUME_UID, + ), + ), + }); }; const handleGroupByChange = useCallback( @@ -449,6 +498,10 @@ function K8sVolumesList({ setCurrentPage(1); setGroupBy(groupBy); + setSearchParams({ + ...Object.fromEntries(searchParams.entries()), + [INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY]: JSON.stringify(groupBy), + }); setExpandedRowKeys([]); logEvent(InfraMonitoringEvents.GroupByChanged, { @@ -457,7 +510,7 @@ function K8sVolumesList({ category: InfraMonitoringEvents.Volumes, }); }, - [groupByFiltersData], + [groupByFiltersData?.payload?.attributeKeys, searchParams, setSearchParams], ); useEffect(() => { diff --git a/frontend/src/container/InfraMonitoringK8s/commonUtils.tsx b/frontend/src/container/InfraMonitoringK8s/commonUtils.tsx index d7f9fd9b7904..e61f64e5dbd9 100644 --- a/frontend/src/container/InfraMonitoringK8s/commonUtils.tsx +++ b/frontend/src/container/InfraMonitoringK8s/commonUtils.tsx @@ -12,9 +12,16 @@ import { ResizeTable } from 'components/ResizeTable'; import FieldRenderer from 'container/LogDetailedView/FieldRenderer'; import { DataType } from 'container/LogDetailedView/TableView'; import { useMemo } from 'react'; -import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { + IBuilderQuery, + TagFilterItem, +} from 'types/api/queryBuilder/queryBuilderData'; -import { getInvalidValueTooltipText, K8sCategory } from './constants'; +import { + getInvalidValueTooltipText, + INFRA_MONITORING_K8S_PARAMS_KEYS, + K8sCategory, +} from './constants'; /** * Converts size in bytes to a human-readable string with appropriate units @@ -250,3 +257,37 @@ export const filterDuplicateFilters = ( return uniqueFilters; }; + +export const getOrderByFromParams = ( + searchParams: URLSearchParams, + returnNullAsDefault = false, +): { + columnName: string; + order: 'asc' | 'desc'; +} | null => { + const orderByFromParams = searchParams.get( + INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY, + ); + if (orderByFromParams) { + const decoded = decodeURIComponent(orderByFromParams); + const parsed = JSON.parse(decoded); + return parsed as { columnName: string; order: 'asc' | 'desc' }; + } + if (returnNullAsDefault) { + return null; + } + return { columnName: 'cpu', order: 'desc' }; +}; + +export const getFiltersFromParams = ( + searchParams: URLSearchParams, + queryKey: string, +): IBuilderQuery['filters'] | null => { + const filtersFromParams = searchParams.get(queryKey); + if (filtersFromParams) { + const decoded = decodeURIComponent(filtersFromParams); + const parsed = JSON.parse(decoded); + return parsed as IBuilderQuery['filters']; + } + return null; +}; diff --git a/frontend/src/container/InfraMonitoringK8s/constants.ts b/frontend/src/container/InfraMonitoringK8s/constants.ts index 997daeaa7775..a8bc29351fff 100644 --- a/frontend/src/container/InfraMonitoringK8s/constants.ts +++ b/frontend/src/container/InfraMonitoringK8s/constants.ts @@ -518,3 +518,24 @@ export const getInvalidValueTooltipText = ( entity: K8sCategory, attribute: string, ): string => `Some ${entity} do not have ${attribute}s.`; + +export const INFRA_MONITORING_K8S_PARAMS_KEYS = { + CATEGORY: 'category', + VIEW: 'view', + CLUSTER_NAME: 'clusterName', + DAEMONSET_UID: 'daemonSetUID', + DEPLOYMENT_UID: 'deploymentUID', + JOB_UID: 'jobUID', + NAMESPACE_UID: 'namespaceUID', + NODE_UID: 'nodeUID', + POD_UID: 'podUID', + STATEFULSET_UID: 'statefulsetUID', + VOLUME_UID: 'volumeUID', + FILTERS: 'filters', + GROUP_BY: 'groupBy', + ORDER_BY: 'orderBy', + LOG_FILTERS: 'logFilters', + TRACES_FILTERS: 'tracesFilters', + EVENTS_FILTERS: 'eventsFilters', + HOSTS_FILTERS: 'hostsFilters', +}; From 4d484b225fe042785106e3bde0afbb00d091e466 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Mon, 26 May 2025 21:20:24 +0430 Subject: [PATCH 11/24] feat(error): build generic error component (#8038) * feat: build generic error component * chore: test error component in DataSourceInfo component * feat: get version from API + minor improvements * feat: enhance error notifications with ErrorV2 support and integrate ErrorModal * feat: implement ErrorModalContext + directly display error modal in create channel if request fails * chore: write tests for the generic error modal * chore: add optional chaining + __blank to _blank * test: add trigger component tests for ErrorModal component * test: fix the failing tests by wrapping in ErrorModalProvider * chore: address review comments * test: fix the failing tests --------- Co-authored-by: Vikrant Gupta --- frontend/src/AppRoutes/index.tsx | 59 ++--- frontend/src/assets/Error.tsx | 191 ++++++++++++++++ .../ErrorModal/ErrorModal.styles.scss | 118 ++++++++++ .../components/ErrorModal/ErrorModal.test.tsx | 195 ++++++++++++++++ .../src/components/ErrorModal/ErrorModal.tsx | 102 +++++++++ .../components/ErrorContent.styles.scss | 208 ++++++++++++++++++ .../ErrorModal/components/ErrorContent.tsx | 98 +++++++++ .../__tests__/CreateAlertChannel.test.tsx | 18 +- .../container/CreateAlertChannels/index.tsx | 50 ++--- .../KeyValueLabel/KeyValueLabel.tsx | 8 +- frontend/src/providers/App/App.tsx | 9 + frontend/src/providers/App/types.ts | 2 + frontend/src/providers/ErrorModalProvider.tsx | 60 +++++ frontend/src/tests/test-utils.tsx | 28 ++- 14 files changed, 1068 insertions(+), 78 deletions(-) create mode 100644 frontend/src/assets/Error.tsx create mode 100644 frontend/src/components/ErrorModal/ErrorModal.styles.scss create mode 100644 frontend/src/components/ErrorModal/ErrorModal.test.tsx create mode 100644 frontend/src/components/ErrorModal/ErrorModal.tsx create mode 100644 frontend/src/components/ErrorModal/components/ErrorContent.styles.scss create mode 100644 frontend/src/components/ErrorModal/components/ErrorContent.tsx create mode 100644 frontend/src/providers/ErrorModalProvider.tsx diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index e2c693b4bfd1..40ddd6eef7e6 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -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'; @@ -358,34 +359,36 @@ function App(): JSX.Element { - - - - - - - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - - - - - - - - + + + + + + + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + + + + + + + + diff --git a/frontend/src/assets/Error.tsx b/frontend/src/assets/Error.tsx new file mode 100644 index 000000000000..9b6924c4fcd9 --- /dev/null +++ b/frontend/src/assets/Error.tsx @@ -0,0 +1,191 @@ +import React from 'react'; + +type ErrorIconProps = React.SVGProps; + +function ErrorIcon({ ...props }: ErrorIconProps): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ErrorIcon; diff --git a/frontend/src/components/ErrorModal/ErrorModal.styles.scss b/frontend/src/components/ErrorModal/ErrorModal.styles.scss new file mode 100644 index 000000000000..87c2ea6edd5c --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.styles.scss @@ -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); + } + } + } +} diff --git a/frontend/src/components/ErrorModal/ErrorModal.test.tsx b/frontend/src/components/ErrorModal/ErrorModal.test.tsx new file mode 100644 index 000000000000..64f880e8cece --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + 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(); + + // Check if the scroll hint is displayed + expect(screen.getByText('Scroll for more')).toBeInTheDocument(); + }); +}); +it('should render the trigger component if provided', () => { + const mockTrigger = ; + render( + , + ); + + // 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 = ; + render( + , + ); + + // 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(); + + // 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(); + + // 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'); + }); +}); diff --git a/frontend/src/components/ErrorModal/ErrorModal.tsx b/frontend/src/components/ErrorModal/ErrorModal.tsx new file mode 100644 index 000000000000..3765345ba45b --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.tsx @@ -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 ? ( + } + color="error" + onClick={(): void => setVisible(true)} + > + error + + ) : ( + React.cloneElement(triggerComponent, { + onClick: () => setVisible(true), + }) + )} + + } + title={ + <> + {versionDataPayload ? ( + + ) : ( +
+ )} + + + } + onCancel={handleClose} + closeIcon={false} + classNames={classNames} + wrapClassName="error-modal__wrap" + > + + + + ); +} + +ErrorModal.defaultProps = { + onClose: undefined, + triggerComponent: null, + open: false, +}; + +export default ErrorModal; diff --git a/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss b/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss new file mode 100644 index 000000000000..a00f2111f3cd --- /dev/null +++ b/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss @@ -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); + } + } +} diff --git a/frontend/src/components/ErrorModal/components/ErrorContent.tsx b/frontend/src/components/ErrorModal/components/ErrorContent.tsx new file mode 100644 index 000000000000..3817b0d82ce2 --- /dev/null +++ b/frontend/src/components/ErrorModal/components/ErrorContent.tsx @@ -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 ( +
+ {/* Summary Header */} +
+
+
+
+ +
+ +
+

{errorCode}

+

{errorMessage}

+
+
+ + {errorUrl && ( +
+ +
+ )} +
+ + {errorMessages?.length > 0 && ( +
+ +
+
MESSAGES
+
+ } + badgeValue={errorMessages.length.toString()} + /> +
+
+ )} +
+ + {/* Detailed Messages */} +
+
+ +
    + {errorMessages?.map((error) => ( +
  • + {error.message} +
  • + ))} +
+
+ {errorMessages?.length > 10 && ( +
+ + Scroll for more +
+ )} +
+
+
+ ); +} + +export default ErrorContent; diff --git a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx index 3735ed1ff601..13b12a317400 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx @@ -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(() =>
Mocked MarkdownRenderer
), @@ -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', }); - fireEvent.click(testButton); + act(() => { + fireEvent.click(testButton); + }); - await waitFor(() => expect(errorNotification).toHaveBeenCalled()); + await waitFor(() => expect(showErrorModal).toHaveBeenCalled()); }); }); describe('New Alert Channel Cascading Fields Based on Channel Type', () => { diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index 8651474347c7..a55792533f4d 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -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, diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx index c0987ccff116..ee3cc7f08d8d 100644 --- a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx @@ -6,7 +6,7 @@ import { useMemo } from 'react'; import TrimmedText from '../TrimmedText/TrimmedText'; type KeyValueLabelProps = { - badgeKey: string; + badgeKey: string | React.ReactNode; badgeValue: string; maxCharacters?: number; }; @@ -25,7 +25,11 @@ export default function KeyValueLabel({ return (
- + {typeof badgeKey === 'string' ? ( + + ) : ( + badgeKey + )}
{isUrl ? ( { if ( !isFetchingOrgPreferences && @@ -246,6 +253,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { updateUser, updateOrgPreferences, updateOrg, + versionData: versionData?.payload || null, }), [ trialInfo, @@ -265,6 +273,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { updateOrg, user, userFetchError, + versionData, ], ); return {children}; diff --git a/frontend/src/providers/App/types.ts b/frontend/src/providers/App/types.ts index 4dd5b31bf9ea..6823b9d82704 100644 --- a/frontend/src/providers/App/types.ts +++ b/frontend/src/providers/App/types.ts @@ -3,6 +3,7 @@ import { FeatureFlagProps as FeatureFlags } from 'types/api/features/getFeatures import { LicenseResModel, TrialInfo } from 'types/api/licensesV3/getActive'; import { Organization } from 'types/api/user/getOrganization'; import { UserResponse as User } from 'types/api/user/getUser'; +import { PayloadProps } from 'types/api/user/getVersion'; import { OrgPreference } from 'types/reducer/app'; export interface IAppContext { @@ -25,6 +26,7 @@ export interface IAppContext { updateUser: (user: IUser) => void; updateOrgPreferences: (orgPreferences: OrgPreference[]) => void; updateOrg(orgId: string, updatedOrgName: string): void; + versionData: PayloadProps | null; } // User diff --git a/frontend/src/providers/ErrorModalProvider.tsx b/frontend/src/providers/ErrorModalProvider.tsx new file mode 100644 index 000000000000..7a47d95316a7 --- /dev/null +++ b/frontend/src/providers/ErrorModalProvider.tsx @@ -0,0 +1,60 @@ +import ErrorModal from 'components/ErrorModal/ErrorModal'; +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import APIError from 'types/api/error'; + +interface ErrorModalContextType { + showErrorModal: (error: APIError) => void; + hideErrorModal: () => void; +} + +const ErrorModalContext = createContext( + undefined, +); + +export function ErrorModalProvider({ + children, +}: { + children: ReactNode; +}): JSX.Element { + const [error, setError] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const showErrorModal = useCallback((error: APIError): void => { + setError(error); + setIsVisible(true); + }, []); + + const hideErrorModal = useCallback((): void => { + setError(null); + setIsVisible(false); + }, []); + + const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [ + showErrorModal, + hideErrorModal, + ]); + + return ( + + {children} + {isVisible && error && ( + + )} + + ); +} + +export const useErrorModal = (): ErrorModalContextType => { + const context = useContext(ErrorModalContext); + if (!context) { + throw new Error('useErrorModal must be used within an ErrorModalProvider'); + } + return context; +}; diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index cdbe9bdd6db9..f58b818ec03d 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -5,6 +5,7 @@ import ROUTES from 'constants/routes'; import { ResourceProvider } from 'hooks/useResourceAttribute'; import { AppContext } from 'providers/App/App'; import { IAppContext } from 'providers/App/types'; +import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import TimezoneProvider from 'providers/Timezone'; import React, { ReactElement } from 'react'; @@ -234,6 +235,11 @@ export function getAppContextMock( updateOrg: jest.fn(), updateOrgPreferences: jest.fn(), activeLicenseRefetch: jest.fn(), + versionData: { + version: '1.0.0', + ee: 'Y', + setupCompleted: true, + }, ...appContextOverrides, }; } @@ -249,16 +255,18 @@ function AllTheProviders({ return ( - - - - {/* Use the mock store with the provided role */} - - {children} - - - - + + + + + {/* Use the mock store with the provided role */} + + {children} + + + + + ); From fb1f320346eaf811582038906610b1959d0e7212 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Tue, 27 May 2025 11:48:38 +0530 Subject: [PATCH 12/24] feat: added custom stepIntervals to bar chart for better visibilty (#8023) * feat: added custom stepIntervals to bar chart for better visibilty * feat: added test cases --------- Co-authored-by: Srikanth Chekuri --- .../FormAlertRules/ChartPreview/index.tsx | 1 + .../GridCard/FullView/index.tsx | 1 + .../GridCardLayout/GridCard/index.tsx | 1 + .../GridCardLayout/__tests__/utils.test.ts | 228 ++++++++++++++++++ .../src/container/GridCardLayout/utils.ts | 61 +++++ frontend/src/container/NewWidget/index.tsx | 1 + .../hooks/queryBuilder/useGetQueryRange.ts | 27 ++- frontend/src/lib/dashboard/getQueryResults.ts | 1 + 8 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 frontend/src/container/GridCardLayout/__tests__/utils.test.ts diff --git a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx index e8e9b484a26f..89a1fa510558 100644 --- a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx +++ b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx @@ -142,6 +142,7 @@ function ChartPreview({ params: { allowSelectedIntervalForStepGen, }, + originalGraphType: graphType, }, alertDef?.version || DEFAULT_ENTITY_VERSION, { diff --git a/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx b/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx index 9a87c228c0b8..dd91469d63f8 100644 --- a/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/FullView/index.tsx @@ -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; diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index c29050ca6bb8..a9d58a13bc90 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -208,6 +208,7 @@ function GridCardGraph({ : globalSelectedInterval, start: customTimeRange?.startTime || start, end: customTimeRange?.endTime || end, + originalGraphType: widget?.panelTypes, }, version || DEFAULT_ENTITY_VERSION, { diff --git a/frontend/src/container/GridCardLayout/__tests__/utils.test.ts b/frontend/src/container/GridCardLayout/__tests__/utils.test.ts new file mode 100644 index 000000000000..84904064f587 --- /dev/null +++ b/frontend/src/container/GridCardLayout/__tests__/utils.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/container/GridCardLayout/utils.ts b/frontend/src/container/GridCardLayout/utils.ts index 72026a3c4946..f22c9e819e98 100644 --- a/frontend/src/container/GridCardLayout/utils.ts +++ b/frontend/src/container/GridCardLayout/utils.ts @@ -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, + })), + ], + }, + }; +} diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index 9c4c80997130..fc337081d03f 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -361,6 +361,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { getGraphTypeForFormat(selectedGraph || selectedWidget.panelTypes) === PANEL_TYPES.TABLE, variables: getDashboardVariables(selectedDashboard?.data.variables), + originalGraphType: selectedGraph || selectedWidget?.panelTypes, }; } diff --git a/frontend/src/hooks/queryBuilder/useGetQueryRange.ts b/frontend/src/hooks/queryBuilder/useGetQueryRange.ts index 172c5c058092..b3941bc2de67 100644 --- a/frontend/src/hooks/queryBuilder/useGetQueryRange.ts +++ b/frontend/src/hooks/queryBuilder/useGetQueryRange.ts @@ -1,9 +1,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { updateStepInterval } from 'container/GridCardLayout/utils'; import { GetMetricQueryRange, GetQueryResultsProps, } from 'lib/dashboard/getQueryResults'; +import getStartEndRangeTime from 'lib/getStartEndRangeTime'; import { useMemo } from 'react'; import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; import { SuccessResponse } from 'types/api'; @@ -75,9 +77,32 @@ export const useGetQueryRange: UseGetQueryRange = ( return [REACT_QUERY_KEY.GET_QUERY_RANGE, newRequestData]; }, [options?.queryKey, newRequestData]); + const modifiedRequestData = useMemo(() => { + const graphType = requestData.originalGraphType || requestData.graphType; + if (graphType === PANEL_TYPES.BAR) { + const { start, end } = getStartEndRangeTime({ + type: requestData.selectedTime, + interval: requestData.globalSelectedInterval, + }); + + const updatedQuery = updateStepInterval( + requestData.query, + requestData.start ? requestData.start * 1e3 : parseInt(start, 10) * 1e3, + requestData.end ? requestData.end * 1e3 : parseInt(end, 10) * 1e3, + ); + + return { + ...requestData, + query: updatedQuery, + }; + } + + return requestData; + }, [requestData]); + return useQuery, Error>({ queryFn: async ({ signal }) => - GetMetricQueryRange(requestData, version, signal, headers), + GetMetricQueryRange(modifiedRequestData, version, signal, headers), ...options, queryKey, }); diff --git a/frontend/src/lib/dashboard/getQueryResults.ts b/frontend/src/lib/dashboard/getQueryResults.ts index fb0324ba5703..30c6e611dc4d 100644 --- a/frontend/src/lib/dashboard/getQueryResults.ts +++ b/frontend/src/lib/dashboard/getQueryResults.ts @@ -103,4 +103,5 @@ export interface GetQueryResultsProps { start?: number; end?: number; step?: number; + originalGraphType?: PANEL_TYPES; } From 28a01bf042cae32ca46b3504165dfaa02803642a Mon Sep 17 00:00:00 2001 From: Piyush Singariya Date: Tue, 27 May 2025 12:48:20 +0530 Subject: [PATCH 13/24] feat: Introducing DynamoDB integration (#8012) * feat: introducing DynamoDB integration * fix: allow non expireable API key * fix: clean up pat to API key middleware * fix: address comments * fix: update response of create api key * feat: adding dashboard * fix: adding dynamodb icon * Update pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.json Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: nityanandagohain Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- .../dynamodb/assets/dashboards/overview.json | 2657 +++++++++++++++++ .../dynamodb/assets/dashboards/overview.png | Bin 0 -> 253551 bytes .../definitions/aws/dynamodb/icon.svg | 18 + .../definitions/aws/dynamodb/integration.json | 394 +++ .../definitions/aws/dynamodb/overview.md | 3 + 5 files changed, 3072 insertions(+) create mode 100644 pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.json create mode 100644 pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.png create mode 100644 pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/icon.svg create mode 100644 pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/integration.json create mode 100644 pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/overview.md diff --git a/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.json b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.json new file mode 100644 index 000000000000..4b64263d7247 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.json @@ -0,0 +1,2657 @@ +{ + "description": "View DynamoDB metrics with an out-of-the-box dashboard.", + "image":"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iODBweCIgaGVpZ2h0PSI4MHB4IiB2aWV3Qm94PSIwIDAgODAgODAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDY0ICg5MzUzNykgLSBodHRwczovL3NrZXRjaC5jb20gLS0+CiAgICA8dGl0bGU+SWNvbi1BcmNoaXRlY3R1cmUvNjQvQXJjaF9BbWF6b24tRHluYW1vREJfNjQ8L3RpdGxlPgogICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+CiAgICA8ZGVmcz4KICAgICAgICA8bGluZWFyR3JhZGllbnQgeDE9IjAlIiB5MT0iMTAwJSIgeDI9IjEwMCUiIHkyPSIwJSIgaWQ9ImxpbmVhckdyYWRpZW50LTEiPgogICAgICAgICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjMkUyN0FEIiBvZmZzZXQ9IjAlIj48L3N0b3A+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiM1MjdGRkYiIG9mZnNldD0iMTAwJSI+PC9zdG9wPgogICAgICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICA8L2RlZnM+CiAgICA8ZyBpZD0iSWNvbi1BcmNoaXRlY3R1cmUvNjQvQXJjaF9BbWF6b24tRHluYW1vREJfNjQiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnIGlkPSJJY29uLUFyY2hpdGVjdHVyZS1CRy82NC9EYXRhYmFzZSIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudC0xKSI+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUiIHg9IjAiIHk9IjAiIHdpZHRoPSI4MCIgaGVpZ2h0PSI4MCI+PC9yZWN0PgogICAgICAgIDwvZz4KICAgICAgICA8cGF0aCBkPSJNNTIuMDg1OTUyNSw1NC44NTAyNTA2IEM0OC43NDc5NTY5LDU3LjU0OTAzMzggNDEuNzQ0OTY2MSw1OC45NzUyOTI3IDM1LjA0Mzk3NDksNTguOTc1MjkyNyBDMjguMzQxOTgzOCw1OC45NzUyOTI3IDIxLjMzNjk5Myw1Ny41NDgwNDIgMTcuOTk5OTk3NCw1NC44NDkyNTg4IEwxNy45OTk5OTc0LDYwLjI4NDUxNSBMMTguMDAwOTk3NCw2MC4yODQ1MTUgQzE4LjAwMDk5NzQsNjIuOTk1MjAwMiAyNC45OTk5OTc0LDY2LjAxNjMyOTkgMzUuMDQzOTc0OSw2Ni4wMTYzMjk5IEM0NS4wNzk5NjE3LDY2LjAxNjMyOTkgNTIuMDc0OTUyNSw2Mi45OTkxNjc2IDUyLjA4NTk1MjUsNjAuMjkwNDY2IEw1Mi4wODU5NTI1LDU0Ljg1MDI1MDYgWiBNNTIuMDg2OTUyNSw0NC41MjIyNzIgTDU0LjA4Njk0OTksNDQuNTExMzYxOCBMNTQuMDg2OTQ5OSw0NC41MjIyNzIgQzU0LjA4Njk0OTksNDUuNzMwMzI3MSA1My40ODE5NTA3LDQ2Ljg1ODA0MzYgNTIuMzAzOTUyMiw0Ny44OTA1NDM5IEM1My43MzE5NTAzLDQ5LjE0NzE5OSA1NC4wODY5NDk5LDUwLjM4MDA0OTkgNTQuMDg2OTQ5OSw1MS4yNTc4MjQgQzU0LjA4Njk0OTksNTEuMjYzNzc1IDU0LjA4NTk0OTksNTEuMjY4NzM0MiA1NC4wODU5NDk5LDUxLjI3NDY4NTIgTDU0LjA4NTk0OTksNjAuMjg0NTE1IEw1NC4wODY5NDk5LDYwLjI4NDUxNSBDNTQuMDg2OTQ5OSw2NS4yOTUyNjU4IDQ0LjI3NDk2MjgsNjggMzUuMDQzOTc0OSw2OCBDMjUuODM0OTg3MSw2OCAxNi4wNDk5OTk5LDY1LjMwNzE2NzggMTYuMDAzLDYwLjMxOTIyOTIgQzE2LjAwMyw2MC4zMTQyNyAxNiw2MC4zMDkzMTA5IDE2LDYwLjMwNDM1MTcgTDE2LDUxLjI1NDg0ODUgQzE2LDUxLjI1Mjg2NDggMTYuMDAyLDUxLjI0OTg4OTMgMTYuMDAyLDUxLjI0NjkxMzggQzE2LjAwNSw1MC4zNjkxMzk4IDE2LjM2MDk5OTUsNDkuMTQxMjQ3OSAxNy43ODY5OTc2LDQ3Ljg4NzU2ODQgQzE2LjM2OTk5OTUsNDYuNjM1ODcyNSAxNi4wMSw0NS40MTQ5MjM2IDE2LjAwMSw0NC41NDQwOTI0IEwxNi4wMDIsNDQuNTQ0MDkyNCBDMTYuMDAyLDQ0LjU0MDEyNSAxNiw0NC41MzcxNDk1IDE2LDQ0LjUzMzE4MjIgTDE2LDM1LjQ4MzY3OSBDMTYsMzUuNDgwNzAzNSAxNi4wMDIsMzUuNDc3NzI4IDE2LjAwMiwzNS40NzQ3NTI1IEMxNi4wMDUsMzQuNTk2OTc4NCAxNi4zNjE5OTk1LDMzLjM2OTA4NjYgMTcuNzg3OTk3NiwzMi4xMTczOTA4IEMxNi4zNjk5OTk1LDMwLjg2NDcwMzEgMTYuMDEsMjkuNjQyNzYyMyAxNi4wMDEsMjguNzcyOTIyOSBMMTYuMDAyLDI4Ljc3MjkyMjkgQzE2LjAwMiwyOC43Njg5NTU2IDE2LDI4Ljc2NDk4ODIgMTYsMjguNzYxMDIwOSBMMTYsMTkuNzEyNTA5NSBDMTYsMTkuNzA5NTM0IDE2LjAwMiwxOS43MDY1NTg1IDE2LjAwMiwxOS43MDM1ODMgQzE2LjAxOSwxNC42OTk3NzUxIDI1LjgxOTk4NzEsMTIgMzUuMDQzOTc0OSwxMiBDNDAuMjU0OTY4MSwxMiA0NS4yNjA5NjE1LDEyLjgyODE4MjMgNDguNzc3OTU2OSwxNC4yNzIyOTQxIEw0OC4wMTI5NTc5LDE2LjEwNTIwNTQgQzQ0LjcyOTk2MjIsMTQuNzU3MzAxNSA0MC4wMDI5Njg0LDEzLjk4MzY3MDEgMzUuMDQzOTc0OSwxMy45ODM2NzAxIEMyNC45OTk5ODgyLDEzLjk4MzY3MDEgMTguMDAwOTk3NCwxNy4wMDQ3OTk4IDE4LjAwMDk5NzQsMTkuNzE3NDY4NyBDMTguMDAwOTk3NCwyMi40MjkxNDU4IDI0Ljk5OTk4ODIsMjUuNDUwMjc1NCAzNS4wNDM5NzQ5LDI1LjQ1MDI3NTQgQzM1LjMxNDk3NDYsMjUuNDUzMjUwOSAzNS41Nzk5NzQyLDI1LjQ1MDI3NTQgMzUuODQ3OTczOSwyNS40NDAzNTcxIEwzNS45MzE5NzM4LDI3LjQyMjA0MzUgQzM1LjYzNTk3NDIsMjcuNDMzOTQ1NiAzNS4zMzk5NzQ1LDI3LjQzMzk0NTYgMzUuMDQzOTc0OSwyNy40MzM5NDU2IEMyOC4zNDE5ODM4LDI3LjQzMzk0NTYgMjEuMzM2OTkzLDI2LjAwNjY5NDkgMTgsMjMuMzA3OTExNyBMMTgsMjguNzQwMTkyMyBMMTguMDAwOTk3NCwyOC43NDAxOTIzIEwxOC4wMDA5OTc0LDI4Ljc2MzAwNDYgQzE4LjAxMDk5NzQsMjkuODAzNDM5NSAxOS4wNzc5OTU5LDMwLjcxMTk2MDUgMTkuOTcxOTk0OCwzMS4yODkyMDg1IEMyMi42NjE5OTEyLDMzLjAwNDA5MTMgMjcuNDgxOTg0OSwzNC4xNzU0NDg1IDMyLjg1Njk3NzgsMzQuNDE4NDQ4MSBMMzIuNzY1OTc3OSwzNi40MDAxMzQ2IEMyNy4zMjA5ODUxLDM2LjE1MzE2NzcgMjIuNTUyOTkxNCwzNS4wMjM0Njc1IDE5LjQ4Mzk5NTQsMzMuMjkxNzIzNSBDMTguNzI3OTk2NCwzMy44NTcwNjk1IDE4LjAwMDk5NzQsMzQuNjIxNzc0MyAxOC4wMDA5OTc0LDM1LjQ4ODYzODIgQzE4LjAwMDk5NzQsMzguMjAwMzE1MyAyNC45OTk5ODgyLDQxLjIyMTQ0NDkgMzUuMDQzOTc0OSw0MS4yMjE0NDQ5IEMzNi4wMjg5NzM2LDQxLjIyMTQ0NDkgMzcuMDA2OTcyMyw0MS4xODg3MTQzIDM3Ljk1MTk3MTEsNDEuMTIzMjUzMiBMMzguMDkwOTcwOSw0My4xMDE5NjQyIEMzNy4xMDA5NzIyLDQzLjE3MDQwMDggMzYuMDc0OTczNiw0My4yMDUxMTUgMzUuMDQzOTc0OSw0My4yMDUxMTUgQzI4LjM0MTk4MzgsNDMuMjA1MTE1IDIxLjMzNjk5Myw0MS43Nzc4NjQ0IDE4LDM5LjA3OTA4MTEgTDE4LDQ0LjUxMTM2MTggTDE4LjAwMDk5NzQsNDQuNTExMzYxOCBDMTguMDEwOTk3NCw0NS41NzQ2MDkgMTkuMDc3OTk1OSw0Ni40ODIxMzgxIDE5Ljk3MTk5NDgsNDcuMDYwMzc4IEMyMy4wNDc5OTA3LDQ5LjAyMzIxOTYgMjguODIzOTgzMSw1MC4yNDUxNjA0IDM1LjA0Mzk3NDksNTAuMjQ1MTYwNCBMMzUuNDgzOTc0NCw1MC4yNDUxNjA0IEwzNS40ODM5NzQ0LDUyLjIyODgzMDUgTDM1LjA0Mzk3NDksNTIuMjI4ODMwNSBDMjguNzI0OTgzMiw1Mi4yMjg4MzA1IDIyLjk4MTk5MDgsNTEuMDU1NDg5NiAxOS40Njk5OTU0LDQ5LjA3MjgxMTMgQzE4LjcxNzk5NjQsNDkuNjM3MTY1NSAxOC4wMDA5OTc0LDUwLjM5NzkwMyAxOC4wMDA5OTc0LDUxLjI1NzgyNCBDMTguMDAwOTk3NCw1My45Njk1MDExIDI0Ljk5OTk4ODIsNTYuOTkxNjIyNSAzNS4wNDM5NzQ5LDU2Ljk5MTYyMjUgQzQ1LjA3OTk2MTcsNTYuOTkxNjIyNSA1Mi4wNzQ5NTI1LDUzLjk3NDQ2MDIgNTIuMDg1OTUyNSw1MS4yNjQ3NjY4IEw1Mi4wODU5NTI1LDUxLjI1NDg0ODUgTDUyLjA4NTk1MjUsNTEuMjUzODU2NiBDNTIuMDgzOTUyNSw1MC4zOTE5NTIgNTEuMzYzOTUzNCw0OS42MzEyMTQ1IDUwLjYwOTk1NDQsNDkuMDY2ODYwMyBDNTAuMTIxOTU1MSw0OS4zNDM1ODIzIDQ5LjU5ODk1NTgsNDkuNjEwMzg1OSA0OS4wMDM5NTY2LDQ5Ljg1NTM2OTIgTDQ4LjIzNzk1NzYsNDguMDIyNDU4IEM0OC45NjM5NTY2LDQ3LjcyMzkxNTYgNDkuNTkzOTU1OCw0Ny40MDE1NjkyIDUwLjExMDk1NTEsNDcuMDYyMzYxNiBDNTEuMDEyOTUzOSw0Ni40NzQyMDM0IDUyLjA4Njk1MjUsNDUuNTU0NzcyMyA1Mi4wODY5NTI1LDQ0LjUyMjI3MiBMNTIuMDg2OTUyNSw0NC41MjIyNzIgWiBNNjAuNjUyOTQxMiwzMC4wMTY2ODQxIEw1NS4wNDg5NDg2LDMwLjAxNjY4NDEgQzU0LjcxNzk0OSwzMC4wMTY2ODQxIDU0LjQwNjk0OTQsMjkuODU0MDIzMSA1NC4yMjE5NDk3LDI5LjU4MjI2MDMgQzU0LjAzNDk0OTksMjkuMzEwNDk3NSA1My45OTY5NSwyOC45NjQzNDcxIDU0LjExODk0OTgsMjguNjU5ODUzNyBMNTcuNTI3OTQ1MywyMC4xMzgwMDY4IEw0NC42MTg5NzAyLDIwLjEzODAwNjggTDM4LjYxODk3MDIsMzIuMDQwMDI3NiBMNDUuMDAwOTYxOCwzMi4wNDAwMjc2IEM0NS4zMTk5NjE0LDMyLjA0MDAyNzYgNDUuNjE5OTYxLDMyLjE5MTc3ODQgNDUuODA4OTYwOCwzMi40NDY2OCBDNDUuOTk1OTYwNSwzMi43MDI1NzM1IDQ2LjA1MDk2MDQsMzMuMDMwODcwOSA0NS45NTM5NjA2LDMzLjMzMzM4MDYgTDQwLjI1Nzk2ODEsNTEuMDg5MjEyIEw2MC42NTI5NDEyLDMwLjAxNjY4NDEgWiBNNjMuNzIxOTM3MiwyOS43MTIxOTA3IEwzOC43MjI5NzAxLDU1LjUzOTU3NiBDMzguNTI3OTcwMyw1NS43Mzk5MjY3IDM4LjI2NTk3MDcsNTUuODQ0MDY5NCAzOC4wMDA5NzEsNTUuODQ0MDY5NCBDMzcuODI0OTcxMyw1NS44NDQwNjk0IDM3LjY0Nzk3MTUsNTUuNzk5NDM2OCAzNy40ODk5NzE3LDU1LjcwNTIxMjQgQzM3LjA4OTk3MjIsNTUuNDY5MTU1NyAzNi45MDY5NzI1LDU0Ljk5MjA4MyAzNy4wNDc5NzIzLDU0LjU1MTcwODMgTDQzLjYzMzk2MzYsMzQuMDIzNjk3OCBMMzcuMDAwOTcyNCwzNC4wMjM2OTc4IEMzNi42NTM5NzI4LDM0LjAyMzY5NzggMzYuMzMyOTczMiwzMy44NDYxNTkzIDM2LjE0OTk3MzUsMzMuNTUzNTY3OSBDMzUuOTY3OTczNywzMy4yNjA5NzY2IDM1Ljk1MDk3MzcsMzIuODk1OTgxMyAzNi4xMDY5NzM1LDMyLjU4ODUxMjQgTDQzLjEwNjk2NDMsMTguNzAyODIxNCBDNDMuMjc1OTY0MSwxOC4zNjY1ODkzIDQzLjYyMTk2MzYsMTguMTU0MzM2NiA0NC4wMDA5NjMxLDE4LjE1NDMzNjYgTDU5LjAwMDk0MzQsMTguMTU0MzM2NiBDNTkuMzMxOTQzLDE4LjE1NDMzNjYgNTkuNjQyOTQyNSwxOC4zMTc5ODk0IDU5LjgyNzk0MjMsMTguNTg4NzYwNCBDNjAuMDE0OTQyMSwxOC44NjE1MTUgNjAuMDUyOTQyLDE5LjIwNjY3MzYgNTkuOTMwOTQyMiwxOS41MTIxNTg4IEw1Ni41MjE5NDY3LDI4LjAzMzAxMzkgTDYyLjk5OTkzODEsMjguMDMzMDEzOSBDNjMuMzk5OTM3NiwyOC4wMzMwMTM5IDYzLjc2MjkzNzEsMjguMjcxMDU0NCA2My45MTk5MzY5LDI4LjYzNjA0OTcgQzY0LjA3NjkzNjcsMjkuMDAyMDM2OCA2My45OTg5MzY4LDI5LjQyNTU1MDQgNjMuNzIxOTM3MiwyOS43MTIxOTA3IEw2My43MjE5MzcyLDI5LjcxMjE5MDcgWiBNMTkuNDU0OTk1NSw2MC42NzQzMDYyIEMyMC44NzE5OTM2LDYxLjQ3MjczMzQgMjIuNjU1OTkxMiw2Mi4xNDQyMDU3IDI0Ljc1Njk4ODUsNjIuNjY3ODk0NyBMMjUuMjQ0OTg3OCw2MC43NDM3MzQ2IEMyMy4zNDU5OTAzLDYwLjI3MDYyOTMgMjEuNjg1OTkyNSw1OS42NDk3NDA1IDIwLjQ0Mjk5NDIsNTguOTQ5NTA1IEwxOS40NTQ5OTU1LDYwLjY3NDMwNjIgWiBNMjQuNzU2OTg4NSw0Ni43OTg1MzM1IEwyNS4yNDQ5ODc4LDQ0Ljg3NTM2NTMgQzIzLjM0NTk5MDMsNDQuNDAxMjY4MSAyMS42ODU5OTI1LDQzLjc4MDM3OTQgMjAuNDQyOTk0Miw0My4wODAxNDM4IEwxOS40NTQ5OTU1LDQ0LjgwNDk0NSBDMjAuODcxOTkzNiw0NS42MDMzNzIyIDIyLjY1NDk5MTIsNDYuMjc0ODQ0NiAyNC43NTY5ODg1LDQ2Ljc5ODUzMzUgTDI0Ljc1Njk4ODUsNDYuNzk4NTMzNSBaIE0xOS40NTQ5OTU1LDI4LjkzNTU4MzkgTDIwLjQ0Mjk5NDIsMjcuMjEwNzgyNyBDMjEuNjgzOTkyNSwyNy45MTEwMTgyIDIzLjM0NDk5MDMsMjguNTMwOTE1MSAyNS4yNDQ5ODc4LDI5LjAwNjAwNDEgTDI0Ljc1Njk4ODUsMzAuOTI5MTcyMyBDMjIuNjUyOTkxMiwzMC40MDQ0OTE2IDIwLjg2OTk5MzYsMjkuNzMzMDE5MyAxOS40NTQ5OTU1LDI4LjkzNTU4MzkgTDE5LjQ1NDk5NTUsMjguOTM1NTgzOSBaIiBpZD0iQW1hem9uLUR5bmFtb0RCX0ljb25fNjRfU3F1aWQiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgIDwvZz4KPC9zdmc+", + "layout": [ + { + "h": 6, + "i": "9e1d91ec-fb66-4cff-b5c5-282270ebffb5", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 0 + }, + { + "h": 6, + "i": "9a2daf2e-39bc-445d-947f-617c27fadd0f", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 0 + }, + { + "h": 6, + "i": "5b50997d-3bca-466a-bdeb-841b2e49fd65", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 6 + }, + { + "h": 6, + "i": "889c36ab-4d0c-4328-9c3c-6558aad6be89", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 6 + }, + { + "h": 6, + "i": "0c3b97fe-56e0-4ce6-99f4-fd1cbd24f93e", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 12 + }, + { + "h": 6, + "i": "70980d38-ee3c-47be-9520-e371df3b021a", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 12 + }, + { + "h": 6, + "i": "fe1b71b5-1a3f-41c0-b6c2-46bf934787ad", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 18 + }, + { + "h": 6, + "i": "cc0938a5-af82-4bd8-b10e-67eabe717ee0", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 18 + }, + { + "h": 6, + "i": "4bb63c27-5eb4-4904-9947-42ffce15e92e", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 24 + }, + { + "h": 6, + "i": "5ffbe527-8cf3-4ed8-ac2d-8739fa7fa9af", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 24 + }, + { + "h": 6, + "i": "a02f64ac-e73e-4d4c-a26b-fcfc4265c148", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 30 + }, + { + "h": 6, + "i": "014e377d-b7c1-4469-a137-be34d7748f31", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 30 + }, + { + "h": 6, + "i": "b1b75926-7308-43b3-bcad-60f369715f0b", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 36 + }, + { + "h": 6, + "i": "90f4d19d-8785-4a7a-97cf-c967108e1487", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 36 + }, + { + "h": 6, + "i": "5412cdad-174b-462b-916e-4e3de477446b", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 42 + } + ], + "panelMap": {}, + "tags": [], + "title": "DynamoDB Overview", + "uploadedGrafana": false, + "variables": { + "1f7a94df-9735-4bfa-a1b8-dca8ac29f945": { + "allSelected": false, + "customValue": "", + "description": "Account Region", + "id": "1f7a94df-9735-4bfa-a1b8-dca8ac29f945", + "key": "1f7a94df-9735-4bfa-a1b8-dca8ac29f945", + "modificationUUID": "8ef772a1-7df9-46a2-84e7-ab0c0bfc6886", + "multiSelect": false, + "name": "Region", + "order": 1, + "queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud_region') AS region\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name like '%aws_DynamoDB%' AND JSONExtractString(labels, 'cloud_account_id') IN {{.Account}} GROUP BY region", + "showALLOption": false, + "sort": "DISABLED", + "textboxValue": "", + "type": "QUERY" + }, + "93ee15bf-baab-4abf-8828-fe6e75518417": { + "allSelected": false, + "customValue": "", + "description": "AWS Account ID", + "id": "93ee15bf-baab-4abf-8828-fe6e75518417", + "key": "93ee15bf-baab-4abf-8828-fe6e75518417", + "modificationUUID": "409e6a7e-1ec1-4611-8624-492a3aac6ca0", + "multiSelect": false, + "name": "Account", + "order": 0, + "queryValue": "SELECT DISTINCT JSONExtractString(labels, 'cloud_account_id') AS cloud_account_id\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name like '%aws_DynamoDB%' GROUP BY cloud_account_id", + "showALLOption": false, + "sort": "ASC", + "textboxValue": "", + "type": "QUERY" + }, + "fd28f0e0-d4ec-4bcd-9c45-32395cb0c55b": { + "allSelected": true, + "customValue": "", + "description": "DynamoDB Tables", + "id": "fd28f0e0-d4ec-4bcd-9c45-32395cb0c55b", + "modificationUUID": "8ebb9032-7e56-4981-8036-efdfc413f8a8", + "multiSelect": true, + "name": "Table", + "order": 2, + "queryValue": "SELECT DISTINCT JSONExtractString(labels, 'TableName') AS table FROM signoz_metrics.distributed_time_series_v4_1day WHERE metric_name like '%aws_DynamoDB%' AND JSONExtractString(labels, 'cloud_account_id') IN {{.Account}} AND JSONExtractString(labels, 'cloud_region') IN {{.Region}} and table != '' GROUP BY table\n", + "showALLOption": true, + "sort": "ASC", + "textboxValue": "", + "type": "QUERY" + } + }, + "version": "v4", + "widgets": [ + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "9e1d91ec-fb66-4cff-b5c5-282270ebffb5", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_AccountMaxReads_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_AccountMaxReads_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "fc55895c", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "8b3f3e0b", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "4fdb1c6c-8c7f-4f8b-a468-9326c811981a", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Account Max Reads", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "5b50997d-3bca-466a-bdeb-841b2e49fd65", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_AccountMaxTableLevelReads_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_AccountMaxTableLevelReads_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "f7b176f8", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "9a023ab7", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "310efa3b-d68a-4630-b279-bcbc22ddbefb", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Account Max Table Level Reads", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "889c36ab-4d0c-4328-9c3c-6558aad6be89", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_AccountMaxTableLevelWrites_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_AccountMaxTableLevelWrites_max", + "type": "Gauge" + }, + "aggregateOperator": "avg", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "ec5ebf95", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "5b2fb00e", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "avg" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "473de955-bc5c-4a66-aa8d-2e37502c5643", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Account Max Table Level Writes", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "9a2daf2e-39bc-445d-947f-617c27fadd0f", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_AccountMaxWrites_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_AccountMaxWrites_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "3815cf09", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "a783bd91", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "avg", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "1115aaa1-fdb0-47a1-af79-8c6d439747d4", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Account Max Writes", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "0c3b97fe-56e0-4ce6-99f4-fd1cbd24f93e", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "edcbcb83", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "224766cb", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "d42bc3cd-f457-42eb-936e-c931b0c77f61", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Account Provisioned Read Capacity", + "yAxisUnit": "percent" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "70980d38-ee3c-47be-9520-e371df3b021a", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "c237482a", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "e3a117d5", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "d06d2f3d-8878-4c53-a8f1-10024091887a", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Account Provisioned Write Capacity", + "yAxisUnit": "percent" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "fe1b71b5-1a3f-41c0-b6c2-46bf934787ad", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_ConsumedReadCapacityUnits_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_ConsumedReadCapacityUnits_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "b867513b", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "9c10cbaa", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + }, + { + "id": "4ff7fb7c", + "key": { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + }, + "op": "in", + "value": [ + "$Table" + ] + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [ + { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + } + ], + "having": [], + "legend": "{{TableName}}", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "32c9f178-073c-4d1f-8193-76f804776df0", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Consumed Read Capacity", + "yAxisUnit": "percent" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "cc0938a5-af82-4bd8-b10e-67eabe717ee0", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_ConsumedWriteCapacityUnits_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_ConsumedWriteCapacityUnits_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "7e2aa806", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "dd49e062", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + }, + { + "id": "e7ada865", + "key": { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + }, + "op": "in", + "value": [ + "$Table" + ] + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [ + { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + } + ], + "having": [], + "legend": "{{TableName}}", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "40397368-92df-42b9-b0e6-0e7dc7984bc4", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Consumed Write Capacity", + "yAxisUnit": "percent" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "4bb63c27-5eb4-4904-9947-42ffce15e92e", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "b3e029fa", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "e6764d50", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "6a33d44a-a337-422f-a964-89b88804343f", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Max Provisioned Table Read Capacity", + "yAxisUnit": "percent" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "5ffbe527-8cf3-4ed8-ac2d-8739fa7fa9af", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "80ba9142", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "9c802cf0", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "a98b7d13-63d3-46cf-b4e7-686b3be7d9f9", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Max Provisioned Table Write Capacity", + "yAxisUnit": "percent" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "a02f64ac-e73e-4d4c-a26b-fcfc4265c148", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_ReturnedItemCount_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_ReturnedItemCount_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "db6edb77", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "8b86de4a", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + }, + { + "id": "a8d39d03", + "key": { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + }, + "op": "in", + "value": [ + "$Table" + ] + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [ + { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + } + ], + "having": [], + "legend": "{{TableName}}", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "6322f225-471d-43a2-b13e-f2312c1a7b57", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Returned Item Count", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "014e377d-b7c1-4469-a137-be34d7748f31", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_SuccessfulRequestLatency_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_SuccessfulRequestLatency_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "93bef7f0", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "4a293ec8", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + }, + { + "id": "2e2286c6", + "key": { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + }, + "op": "in", + "value": [ + "$Table" + ] + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [ + { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + } + ], + "having": [], + "legend": "{{TableName}}", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "6ad1cbfe-9581-4d99-a14e-50bc5fef699f", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Successful Request Latency", + "yAxisUnit": "ms" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "b1b75926-7308-43b3-bcad-60f369715f0b", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_ThrottledRequests_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_ThrottledRequests_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "28fcd3cd", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "619578e5", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + }, + { + "id": "a6bc481e", + "key": { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + }, + "op": "in", + "value": [ + "$Table" + ] + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [ + { + "dataType": "string", + "id": "TableName--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "TableName", + "type": "tag" + } + ], + "having": [], + "legend": "{{TableName}}", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "fd358cf0-a0b0-4106-a89c-a5196297c23b", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Max Throttled Requests", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "5412cdad-174b-462b-916e-4e3de477446b", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_UserErrors_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_UserErrors_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "5a060b5e", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "3a1cb5ff", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "17db2e6d-d9dc-4568-85ea-ea4b373dfc5e", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "User Errors", + "yAxisUnit": "none" + }, + { + "bucketCount": 30, + "bucketWidth": 0, + "columnUnits": {}, + "description": "", + "fillSpans": false, + "id": "90f4d19d-8785-4a7a-97cf-c967108e1487", + "isLogScale": false, + "isStacked": false, + "mergeAllActiveQueries": false, + "nullZeroValues": "zero", + "opacity": "1", + "panelTypes": "graph", + "query": { + "builder": { + "queryData": [ + { + "aggregateAttribute": { + "dataType": "float64", + "id": "aws_DynamoDB_WriteThrottleEvents_max--float64--Gauge--true", + "isColumn": true, + "isJSON": false, + "key": "aws_DynamoDB_WriteThrottleEvents_max", + "type": "Gauge" + }, + "aggregateOperator": "max", + "dataSource": "metrics", + "disabled": false, + "expression": "A", + "filters": { + "items": [ + { + "id": "58bc06b3", + "key": { + "dataType": "string", + "id": "cloud_account_id--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_account_id", + "type": "tag" + }, + "op": "=", + "value": "$Account" + }, + { + "id": "d6d7a8fb", + "key": { + "dataType": "string", + "id": "cloud_region--string--tag--false", + "isColumn": false, + "isJSON": false, + "key": "cloud_region", + "type": "tag" + }, + "op": "=", + "value": "$Region" + } + ], + "op": "AND" + }, + "functions": [], + "groupBy": [], + "having": [], + "legend": "", + "limit": null, + "orderBy": [], + "queryName": "A", + "reduceTo": "avg", + "spaceAggregation": "max", + "stepInterval": 60, + "timeAggregation": "max" + } + ], + "queryFormulas": [] + }, + "clickhouse_sql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "id": "713c6c70-3a62-4b67-8a67-7917ca9d4fbf", + "promql": [ + { + "disabled": false, + "legend": "", + "name": "A", + "query": "" + } + ], + "queryType": "builder" + }, + "selectedLogFields": [ + { + "dataType": "string", + "name": "body", + "type": "" + }, + { + "dataType": "string", + "name": "timestamp", + "type": "" + } + ], + "selectedTracesFields": [ + { + "dataType": "string", + "id": "serviceName--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "serviceName", + "type": "tag" + }, + { + "dataType": "string", + "id": "name--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "name", + "type": "tag" + }, + { + "dataType": "float64", + "id": "durationNano--float64--tag--true", + "isColumn": true, + "isJSON": false, + "key": "durationNano", + "type": "tag" + }, + { + "dataType": "string", + "id": "httpMethod--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "httpMethod", + "type": "tag" + }, + { + "dataType": "string", + "id": "responseStatusCode--string--tag--true", + "isColumn": true, + "isJSON": false, + "key": "responseStatusCode", + "type": "tag" + } + ], + "softMax": 0, + "softMin": 0, + "stackedBarChart": false, + "thresholds": [], + "timePreferance": "GLOBAL_TIME", + "title": "Max Write Throttle Events", + "yAxisUnit": "none" + } + ] +} \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.png b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/assets/dashboards/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..18a5f314996283d03754624c221eb6cd790b3751 GIT binary patch literal 253551 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솯mF-*2L!&Sz%Tfnl*UoU9iqKCuDS)GU4VHfvGP*0>3u+Zid z?(I@Dqo9pIDb2TEWGzUT+2N|Lo6gq%VR@9Akq?&SYr7E ze&^5V?xkq*8N&nnS_@Q@+Bmn@rbURhV92n4y$I#!s%pO)$*r^r^_Czmiwgs*+Fj{e zhf~m-hw8q zT3drgaUA;JgOIDQ^w<00@19tX$-An{s1H3`?wWb!2N8}+^~fKf#>(}jwl@al>ZTmF zPm7=%VO2r}cU%eUhM3;uaG7KEAh2obB<`Z*cDChP$m6U8*S|*7P>SQb@!`HQl#>JV zYIutj=gdw0zQMV>)R|r1dszwI&S&5#ozH3T!F%`9lBb$u5)>h>2NW$7a79yeAFRAs zgR{G5KQ532)&ZSbM=VhqjebvU+Pez{Vl6|a!R0i+_rXa}FVcgWp=#5kyxx)rwF;im&PjxYN|+j;2jh99+g@yZtL@$$y>$=kdb#KZ=x- z=icml0OwN(u8Ll0A23t%i>T&F^=Q&6%etj6H0!xjnb+FBOLV{q8;F{T@E$@VTL=wZ6 zvqh9%g+tEF`8T?B#{6`l?1NX3;-CkQlTg|L0jisM3ijSr=}Edl?Gt?%V)*$*@Uj=Y z;5)n8!V;qCfn;&Qab5mLCn}?`UYfF5U@n?t z{+$owwMj}<{>04wM>k6U08liHeENNbIaRTBRfb1KMUi9Abrabu#o>oCEV2Z z111s_oRc%I{LT7jMksPz!HfsICd#}oRc{OlRC#nx$?#O zB;9bH%qX|bUU-a0sStZ=m7T#^!(hJA6zhhJ!kYFxQ>LK$OYrZM7G)2e)yo!vH%+YL zS(KX3)inj}r&?MME*QI1Ajc(-Z|b`n_P}P4(ddP9Q|5D@#cC1W8IYx#Dfm)5!0Xh; znr;q;0!kzlT#59h$opTy;O0Ppg0#)gio`&_(}nv zNDG2AKu!#Vcs(JKxcjAgrp%`0q4;D|7m#K0{goGf=VK4v1$4RU-Zr&?l#KYyG5J-X zs*VS%r_Npz(VwOr9bPP+;yGQfN4esQzaoy?Ydi9QoVSm$fAT+<)z}(&s$#2g`l}Wk z_Sd5J47~Fu)zi1q#@x;1TI>dVN@;&MJEzMi$^2VAxB00`nwGotfk}}8uR0lS(BJn| zkF9HGY=!lVVH#}h^-KGzZ-O~9$SExxHH$$GI;jHptwDQ&xN9=Slo76RBy)4UpE9E! zI{tClgr_qJkl*?9zO-aK{pRfhi}~G}M3k0!kaBe)P9`ULByfIVS;Bh(oN$9MjaW8v zI0RCS$G*!sUpl;Bm$+E#bIBTmsWXu6&C`F748qt7J0yL@S=NWnltzy&3Q#pSnu_uDa@*YJDZAXQ6Sg@kZ}l&UQt<_2LrxR3Kf3D!VH)zEH7i^Hp_6dX;t{(DDo`H2QC8Uo!mA5U zC0R+?ihdF~Ir96M>M6TDlLJxj)}USCpxT{lWRtqf+(Z2onoUWL&|3pEOIp#ow^nyW zqLo&7`{lz|!^fBG;RNmx+;S@=Tf#fuPZGiTPRg8iX;Ph8L~Or)d(&#$l~IYFZuPxn zRMRM_wtCLby9%Mw9;$1&@+kz&Z@DdjhSF{{1nG$_=8d{-pxOSrX*2&6e&KKzanM0G zjOkv_DeTNkqr#pe7Yz!U}7P?=sbXMNX^1HxS})6tES z9P9jOpNyH%FFvS$7bo}pW9q_@(y&F<+NNPd-Q!(N-2`tkmdr;}xl6Qul+t%b5MP0_ zx~TMd4HECer+p7%Dj8S|vUQ7#r*bop&B+K}%HUw|I-y{Gp5`REjjc<-&;@^8uIvjx9fEm$G6@V+Wa_@})wRy|IEeGrA=i2z{5~RorPnBq zaU{cp>8=*!eGSUpqpH1w7NGtpBVH2tt#9<=OHaA$BW1H%c;u%-o2V&YX3I%&D`XcF zpE3Y57@|c>`#WnJpbBDDWP%_-J~J~ky%eqisV0T<8}Xjz9Yc;~MTPhiNV2yRuUjmZ zfON((=1v!*&!k^_5s}Da@q?zW{EOj%TQ+sq!@k=S+^Rsdc?^Qb1Tu5H|* zidKbM>qsE2RMD!SqD&E@QjJyxaR8B71VjjfNEkvAEh<$saRikiT2!>mQzAnMf)E+Q z93UhS!wexzNeG#KCyIS*A9&u?=l8Ake($&Vqg{Y^?wqsFKKtyw&%Um`#?N5>akzY( z`%PF|BcBq~S2oWrvm%0@c;@A{6oS@>>S=Ur=vNJAW^Wm!zP)?M9n?SSCCqL-v&v*} z^oxp}G+< zq#M-aARlX+0KtAy>Z>~zXUw=Xf<;l!CabL2Ep~#0O~n@2jB})i1*NlOfJl2qLG+cR zi0YE1517X)H|w(>7Y?jROx46HB#H{P2$5UZ5(D%7xmYcG#?r(-=$g=>H!441Qg=d+{%Dj?5xStH<*%l0MbZ7)E=A*7?-8NeIsC*{X-g zszB3W*j>X7`_kvDQw`KXQRc;2Q3J0qE%Qp>Yz4&w!xt^>4UP|g30D)WKMIN=Ok1V7 zo{GD9C1+}HY)Za6f6eHFlX{Cbr|o{-KH<5ksq_gq{NMsT^Td?g^Mm5ZO9S){nTOO?t#poZs?ap{1!hf?5)?Pa!LvZC?dA~(hk_V)#4FQH&m@gAV6;H zd+HU^aR*U6NNv@!^{V&uoRgXUrVmkRP5g1bQRdPBE~#ry=C&H?8wo#ryucH9-E{#B ziL25R9;qssb6Tpt0c5Vsw><%!e;F1+nF8E9uXC=tRVQ*~2kGfGIzHQxXfxhz5)$ZNS186n`8LccQM6aX9+c2J%y@MX~4-p zf~;ngY?bMk{g$9hm0rkA*-}!Of9LFroj|W}5Whg+Bb2LQ!2gSzm|oGkWXAcP8dAKty$QJu3iw`ncBMeKmHfTnz8F#}U6{$y!j=3!U5F60+=wtmP;ksTzEGp!+^v0%5 zfPx1c5TLw~#JP&g|9*4k;o@^IlwP7~hEJP&H>H7K6SN4RfntX2;QkE9_)%#V0w|?OC6$wCfTI>}I+#`+01*NwgLK`rCWAFPA zDkmW|MSHi!Fi)(?4(jnpkeMct`hHrz?`6>8#m8`wH_zsR+-^r+>YwGV;hZ2d%?~$c z7d^1H*(Y>GYxBFiZL!L~_ZhdYASg@?hK^nFHsWM$AtvKg{-0m5l$neYT zuXKPyWV=Vs7JFotWbN1JuRe0bUf|k-0^F2 zL+Qo(BP+XzxPl#vS3!@kA01!(_5Mu5@?KD)*!Fzy`Qf<5AjqFMG>{Ot=AOzySbaid z_i3+m;!sfzJ#CX&@N2J>)yN%uKlzyat;l}ykAZ?$ z6f6I#oz|x=l&vcQKK+`w(2=sCVH%YDD$(!$hw%b2D6y=!OlMBS#ew>aI?;vAB-F}& z{n>xv75iAjy+iA0qLXL9sB5+@CJwKY$R*hy)gbfScqV%_$jA%4n`RCc+L~pf{T(uufW}?DKSA~ zUX!SuOnxMLO2iTYV1@)i)`F?fDVKFVBbL64i}>PF|8r(*_Yy!A;d$xh!Y}gcpWTS8 z$vOqwzKE&GM|9($v%tb zVlIH#CESDk@L7_7E%rA#^6AsUEJ^&75ZMMQ__U1UVloxT)#>ZW%JGeronupu?qv@6 zc-6t3Q$ZVNUH;{h*ldFZQ?faHUn2u3wLFDM!`!K)lKc8o$v^Ky%-_HOpBD+a0xX)| zqq(1d|6+(W`jd-BVz)&9RVIHvWv>KG^>ehWryBikgGqQGD~8!QmA>zOatF*^>)XGu z-u>mIKWaNUcP86&YIxvrK)Z@6=ghVKD+KKG8t#srl)N!jBX>&j5w-6?t^Z{M`=~F< z)Fzdt$8K`&ImXq~;B$fGD-2NcrxJX~XTi6V4~eFF{Yx_d%QVh^l{=-jGbX9Y><`f! z|00q9n&uNOPS`^D;GYIF-OD!<)3sNQb-RG()}vd5ux?X9Q6 z=*_xZ22gh9?01@f>cL-s=(#o6|2PR_km=rAQk%r^kj2bg^1ny=?f zCA&VmPm*sE^J+|{dLZNpn7dkjZ|+q3xVwAu+p8PJIAOI3yFR^5!MN>}K=OB)Fp3@^ zX2x7wf8n0OcA*ukT9Wb-V*LFxiq`Vq!xkXV)_i~Cj@OC%#g$&$It+K{-7Mar zU*uy#4@_-5t>@B}oBAjKGngHc3U@0Arwe3l{W9#Jm@COOITtqTtM6ycUa))CoNq3D z`D2aZE5of!#Z8aD{r$h5mcLmz#(2{tpJ>DR+Dj+?{({dB7JmO@;pLV+8y+|={Fg78 zeCB1uZk&b}FO{Agp_zF3%W-^u4fyL;<&a>C#p17~8t|tF{nynvs@O{U{ktbBhSpCv zxmlNIAGkC(bMDbKhptWcb@NwTmK!&>Iuc=NJsGE`|HjevV0yuNYiCa>M&+oS3KmQX zN<87ZdfG4i{^$?0RV%GFTwS;4Uzg+4?7}_^O2ckUjGSiWz@rcSoFWsad&xJKcEgwT zq=qHS1Ua&sVTADMB=G%hb0DPNcN?!v_k_Im^UQEN;L?I=Rp7lgkkK3dL(VtTslYd% zM|8%n!ED-*pL!28Ag}Pw$mQw2YSvd1-%idAV@=nae%a;{XQ{ZU6qsS>PWQ}{3v*-v zY1vd$@`aeqYSRt;((Y|w)mCV^OgrdHyH`(YlC6+==ie+p9K5KB|J(At)4ghL%;Y}N zc>mhQbb94(S>Wr0(*H64lhRBjDyKJ}rL;&$?yjF+lX@2VI$7~AEuX?dOseFcW9l#- z7fkn}%edWhAP;3K_p4JEPq$AxJ`XA9;=i{`KdQJz*`sWjVhTSynT(B+b?V~M9qj^R zSy+h=2ml?TyM3g0Sb>>$v=FUdZ%T3!$$BmH-6~h9!?8uXy5+sZ71a?xTsM)8)sM!265x|w4KGc+D1xT!m2tQ?gRi;fFvYKn20(dBY&Xb@*)~g1 z?3Qb4P;Y{X{2^;7QJt6DpGxe8OgFXNPD9^JYUPsP0$L}!?M>!f@sVkygnINm zisZ-jPRnjO?Wr$JrOIYkPRrkM=)$3)t)&2>KtK?7NKu6gs%-ibp@Dn47JwVKbVD3y>tu4 zf7iIsw*D4cnIHPOSrWyCF%Wn=n zp{kOEwC)iT?{4D`FwH!uxN7Rh<=YlM(|BkP&=fooHIKCdU>zba`kxBuYj{O^`|Y9T zyN7gI%oHOju+qUyx;8q_Kuy2R$xI@s;tm{Hq}x`&hFD?b;%;VMrA}7OUY_T@^+z%S zy)s(70$-VcyLnziDzVP(G5m!EjS_4120Y>z`>sutn;bC>b5Qd(YQ=74g{dZ{Wu zi1xxhFcy=KsfJFq!<2it@cp_PsEV!n=|vO%V$CSNp9){6!(i$&cO0R88|5TKyAxf} zJi&0JNI;c7$8=~x`;FRim2lnw>T6Q>R^BhgasucuxyZzOz4>c~m5u8Iq1eWqh(u_L z#X>*-S%OH8wAIQXxKk)*m>aj4n~M42E>Wk?Z$uBIBU-~qRqaid=s{O&M@&F83uIba zVTCBD6Du@g3-ZI;>pXN7l+W_e1|=JZTD$e!D$@e-p;0EF@|rg~SID6Qs7n_3mLWm1 zS-3!|Qx(x2H)_K4r(5zQ0P2#8q!#1tRnEGf_J1w<5TTPLqAD;vbYPob0tL1 z9+Hb4<}8|U@-%ogPnHu(s1UtoF;?KFIS8E6mL7m@m4K4Z?7&mV!J*udpAxG}{TOQ4 z8lC*1Uxfl)jChl<$_J(TOxeB-)w$fIl>xWsN6#&iV(4HR=1o#Hrgw@ z(YU|OrGbW4Z6&p*z(L9)2`Nhn%(fuF+kb`$8ho+{D+hBI4UW0L$PRajhE=2#w64*Y zwYcZeS{cM3simjts6JfXC;z%hO+cU>cIsX7y>e4I?SXpn)@MI~0~LaujgE#6nRts% zmU5=iyH${{>QpODN5@W}N257j07;*iNZ|KUe|o1wHtOVOZ%`>gk0v5ID+RHPC&p5+>66 zF`jD!2zy_eh%x}YokSpp zj4ZSTi9lW;RYF0MWvZ-UQyfkN!RyqQ%13{QoPzxLIx?nD~Vi8EeG zB@^6bE>cQhAnPh>XY{V%3<`#)U>0D;+li#q*r2_V*TKobKIU*b+<;=@g>G?$XIc#A zabniy&!6sqS^Vv%?GyQN`!J7?Mh!SzVdtCa9H7N#=Xj{Nkz3Cnl9XgKvci9kh84t( z2#zD$ZgIVu)0~CZEe2g+1>GiFXyH)o+a_CFnC1($HXJcItL_(JPduR&l$LS8QKACM zw(px5iHC*H*?|F=cCkD1-R&Px!zJ+SxeCD(ww4J&&8Wi(Cg@Mdq8o6|90E)Pzk{R4 zo~I2rqGFID794=6aTtpNQJ80nJO>F5T_H$yH*c@d;S>nCP{lhG2T3+n@kP@w!o8cA zNs?QGPRv-Y&vb5^)B9zoYE*3Vu5a$4gM-#PBbF-GB(D>Z|;JA}!+7Qk94 z?B>O~b$`XnAj&FLO~<#Qhf5xr_*mfF9&gEbw-&`jl9^~!+pBJY4fLWSO3wApEkm`q zT7#R90G^QPjdXFG7_lCR=IlZ?-4O#!b&n+K5FptM4Qf!hhY}=#D|Y=lbqG_qTY2h|jPhGC3j}~hAu~c({)#o$ zAI;3G9vqL;@Jfg^4kTcJQ|Z<5E}2=Ju%r#}=%)k5_J+ayQ8iLk9vNfC*4?E@TC#fk z+Z2j$rLdO3a|YMUe;iqbvxtWGb{rpgcF#PHJD>{l^0B^|laJv)2~#C4(#?I@Uo)_h z*Q1Mlib1jj`e_Kw;IsHlcVfVcTbdMW30t`XG_f+l5w6$0BW!0~%Glj4Wa$m1CU`Y9 zCHW~GrHM736ld=y0|%nusyMWutIxBtTe}h+`p7kk)Jxw5{r9PPXm{8X;%-fDZ29=H4%c)Ih;|mdw8^l2)(=Q z{0#}$$rBd}tgMi7c>Lo=@>sa|KF%lm{zcw+ZMsOG`|i*$QN?%gl!VoA&0ks<~Xq=iNd%{CG#%ey^MdGP)wafd+sY zgXe&6e-Pax0GFZZf+o%r3{FZBHJ?vRK5tts8N{b-+FK%LBLLJQma2 zAb|kgk(yi^uz^g~?btK-&{)L#17ERiBB0tEoEIbZj#rj6JSesjqS@ zI_y;yT?Z7{0B*bTG3$XP0qj_RhFshW7z$Bpd^5Q1ed(N_na=E`;?7B9v3X&)7R-C`CX@vsWlf;3t@8C;$b9*Uh` zblIkW7Ry@4;Z|2)vA+1#xX6meuD=OYTZf#FI4zGuL4r5*ZvXFevEG>oXpZusvKrHo zJ1k~tj>cL_$iPWbl|C~o=jo;;nXC2W=K-i2~38mxC{dh0eiMha4Os_JN_XEvDXs=b3HGpBi1t1N{t`prJ4N$fGR)E-viopfO<-Q$0*>az^=i z6aMG3!zaj{3EJ(@up%b2Rl3B0eJ*csqFw06XVC2Yz}2sts8?J$r~({$Bgse<-jk^5 z_m zuj=!?5JK4kU47Zs$ce=V@nCkNuCAwk3UXB8~#yT+-7w-KBu;p z8V;p|1t-r!jIMo#gCasy{B37ryY?sGMZPD(!*i17ApGOwacXc4m5nNH(Pn8?&dpuj z?op23#PPQUu|fx&PUcvheV_}bkS*;p2-NV3J>!{Vl#d~c6!X1GZ?4uKKyD(dIOQS8 zyj;luJ#TO?Q+hbo7#xBw$%EtpK+B)58!CS(6#IDRl%UvRnF)WVT0y{s2T389WT)Dr zdAbAfP-c9$jMFCdWP+lW)@}sUKPlF)l#B+RdYga-Lndb%+XKXSntf31J`+CN1bJgi zZqM(+iT+gP`e<)&5eJ8(qpMh+(J+pFxL>p=T3{ZnpWH2bGk&Scih2^#$XfvaQvLdb z^+~D-!#{|-S)0zPHS!W{H1NJB;Foi)-)WABS1(X;`xw|kCq`ow1doEmm=ic4W2Sul zE*z0S6mz4t-HKmaKFYWpFXl2h_2a!hMWYI_;bYu2nQWn9ANWlq9)ASKuC6yMf3gdw zVfYx1m6_Zks|NC2827ksG$#Sqe>24_D;AaqO2ieJ$EnyG;9PrVa)}A4;bDBUMSECO z8P5k*I9r7jNJgsW#$lQsk!YGwPjm=$kf$U6u#)OTgUCvR9{#@tc8f5DgL!;Ygk!^@jdYj0`I3&3e(rAa3aLSDU- zp#3`BpC9;;U}nNA^zsk&!y%wNKzW{WWI=L$y->5B660eqqL z%CM*z)T%a)0?sN*ljd(cY&-B$GbsD{ELm1JA5gEgmdd5&abEQ;(y>8VLxL(98XM>! z3E{&5Da|Q~)Dj`g-_al97~Hv>c_Hx*&$&6SMq#=tkh_G`;ubB;-_;+YZ4u|yk4$qW zax%4dz0*u<>05c@p9`n6a5FfoW$Z_MKMcd*6$D5py4a}oSK@}8d?QF_I$pnh!!J7S zs&J#2?u2=WQ8Qil6$qo4_$}kt?^Z+?TWa>7TZaF=<3iU^eR~Y>jb7Pe#htuv`*3T8 zD=#}a=eQ@I*o99ArCUbA2dSDINoO3bie;l)&{Jd@el*%3I*%F`+|+^sfb+4l?wG9? zIu1zM)vB|di?0cS`;|VHmYk>O7E2d{ zy;bE?EP5RqZ8Bt%!f##w0C5TDGkOZ~cxc(vNLX0B9s7<|+e4>EpcY5bR@J zPfkvw6{+a!3g(sT%XmN)F46&C<{JGo5leY2SAMP}#bDQfali57xvA>Ntf$Fu?^R5aV6fUQb^W%^fXSVcWLWZv)(JWkbRT z_dRz8sPs3MigfV7oAKa-I8zn4Y06wdVG^ILwR6>iw5WdaZ~t%?Xuu(0Y{nYjCAwrb zrY`-*9*5!9e(uk|ATCjoCjHm-;m{?byH=0~HFanGp0y7mO9+!rt&bi)|Je>B>ABZY zSsz|riCiC{wItAI6V~_{cnRgqyQ>S+T8et==KI~hW_;ob@f%XZ50K{{R3Ef&xUvPg z0PgX!c%R|d@rTskTCV9kx)i&&`B=E`{Tr|o*W$k+W&I!>KkDz)M7w?;KMQR`V>54R z>25rt3STq0F4^#>r_aki94KZnZ$Nc7+N&ZaT||!yK7iN;G~dwp?J_`VF@3Xn2J0cl)FCe~aJI z@N}*8O4@VP_6^GeUoJ-c;N@X9KK!(+Fp02iqsDAu2A)D*jc_>j7=qo=cIdRllh}cc{A&|Ly6=oAEo^D9Pi!w?7mniEjL`a($uO zqUq=T?5XO?_6;|6kl!FYUYhM2U+8>Dqsa27!K173dzc3XtQ|HGkJo71;rbs+KntzqW;%y2RjrtQ-UrPz zbc!rj*|&a=2O_#Lw5%x7_)7eVwx`=6SJR#^X{Uuho||cE{_Nsc%@xxxLeQl1{xMv- zS4_BuKDY`0%Q=q>!#ZCP2^>Z@=4P@tU9)>QD|*ZH^S*b@*zQXF*4(T;kmpZTm$z$Q z*%Gh-@#?jitKrwqht!J*YCBcjFfj*u!M+KYb`gf})^++mn^w0i!oO2-qgXG_**aU_ zcKUg1F9~?P0O5SmBh2s@GmW2$EEd@IeS<&d7hdlBT~Va|6)5z)YUPCCow}t?y8Y8G z0(af>iuME3>NZhzqiSVH>Z%LQbG%5?&)Z^6pYKxqzu9P}4UmTdzqQY`3LTmzW7dCN z1lnXEJDqN890MRkPRh^Cs?SXkQEYHybVVrP2R=1_t%yebr|!fEi;mV{Cwt zoiR338`&9SGvm$7crzbUku%=RmoDXuH=|7V&xA`e;nGaF^tUi^CS002O#I&&F3p5U zGa=GUh%{{;Y9>UQ36W+(q?r(Dimdxgh%^%-&D+$c?PlQt6~&4frZSJpFE)>G6C z%v@P3%dBUvtY>OsW@=*oRtq$9P5nQ6O?`K6W@1vt4a`_PG#!vh(%h0K8driG{ZN%m? zqBeA@z3&@~0YHuy*X@nZfSrrlGG* zgySx#;#BW}33~v=Zgct?)X$Qy|HH~bP+UB$te+hLRiSaw)_>xo$J^qmI#?RPrjs3WuP<4VK6vZ z6jrQi(qO)8I0#)I&2EqYbZ|CfAjZEso30Ujg2+I_bKd_t$}`Ku`jP|%fa@If?rC@n zAUQuZ$ zKg*G`tD4;q@SY_0P)#%iZNYi=Zqalk7PMeL%WpoLDg4GuQlyakfv(%kSB~)K-OqdL ziM;as^A%gjc63kB6=n>|3E*Jq0>uNkgqQr^k6XZhsY_gTJ!$LNaN@{raruiKZFnTY7Jnt(3ss}Vl{nR z8OrYjSxegR1ybP4`r9ud8}GW^pFkdVqU{Q30{hx$-zniOdsv+7Q23^k-y(h*kvl-d zHXHAbTOQTze`~o~ci$Ss*L`wx>A20SyN1W^h(sUW(SNz~)*?Kl^>Pcq(IsgFG~7Y< z7puX~zy`-0JmrgIUG9Ja5-=p7vv&r%BC#ZP7fTopm!^Z_|80InlqM;k`j`mVSef`; zBcnWS*Q~xRRB?84{zy4*G&S~OMr`N^M~$Yh;s-?!+z~!O`o9loh(oskIs}%Ra;FPb z(^x8}CefW|Bo3oAbYN?lPTs&>q4?%%y@6OQj;osK_)&@Ul1!9YZvjunv_V2K0M>|6 z?TbTKi@fM2h-^N`pH3mkRADHM+p!*1QB^A)FFvIxWPDG~XXF*bRI?JN+}HmBXy9XA zymDXPSpWR{LMnMGArgKKvC+nt2W_k~Ja$wR_<=_M<-yxKH}a_~5m>7mKVpp=KH{wQ z5##iqQ}X%%6u`~LQKD!)Zeh|$lMH1y^%?(&4p%|}0q~qmDn_HcI7$-tf$~kIS%l9J zbdGO|QGRaG4Kp+wKAOh?P_>pk4E8f*Hcs?dC0rxqW$cCWgFH{FLIzqNp$JiZBI&AMg4eyiD&3mbHjI|EGC%70uT{Rxu=0G8qMwj7Tu{6E$| zIHI(b*P{gO37-%bf(&!WgW+;>u6A@#Fce?@$kpQghUg$0D0Wi5AJsW^UU=E<->AOY`OU4QID!$=>oCEOEW-QSV>Z8Taque#J52{JauBmFYHX0OP zOBo}@iHI2CBVlzGmu8W+)YiLkG(;5k5g{KkbXhoS;4==1XB+KPki!Zp)@9NFK4vjY z*v!pEgvh;{T9b0bMiUQRcsKP5Sf-juO3gI*4>Ys=BVr?-?bm1(8P|Zc4g0jR7KpDV<5d6mB@MHkJMw0*~OL`nq8!?Ayv2V zauexA$TPsT(ymtE1C=x*n5E0D?8+QM{az#k;K9eE2=u7;hyvvGCc|$#j2xDOCf3Om z|HNdkuF6NG54P5iUEDy6&h$i@Lt}x#c{;^wpgEn#O^6FEiX51ba%6)d5=#iH^b_WB z#3amzWs+vl4A=V*#kE&I6M)@YwA2FShbB@ralc}?>Izd@%dm*e3Rv0N=rz(&#<$L%vioG5Kxz}d@iam z*)5ml55aN1=bs9dOWh{-bT zk7$E9H)+rY4?V-W5m6TRo!P;tHx}WT^2^G_t`E>|I*A0JKN9dq$bnYKK83I^QTXEO z%0d6-F(G{h1LMog2ZE6kdi7p+_N`F10Rlt^%+b=#YPpI-3O@tZSwKJndH}L>Q4%DX z^lFmOBTm2K=k8w6q1=PvD2fo4xtmIiVBInPJWoeht*T-yp_O4}ivf^&WGlp-KHk&| z+LB91{3Z4trx<*ZwS>nbw}8GbE;w1gBM&f`RQaU@4-e+aYXYKJJCPG(?nuzy5>wpf z6ch&JxbJ^fj&S&nGT<*?)npRj^4C1^FlB&%%dcSP_eArSq4`L~FjT?zzb%Z1s=I<# zv&iU=_fxNlm^k=D<_=#?k9Kw=OuIDvh}Siv`ASjQ>ilycucLJUnSHj%I-pM zk``~2OhG=B^%f`q8OSh?-&~7;)>rQwt)kyO1fX!D7`d`bCGQYTgINGL3$%Djwxmg0 zdj9x}!|6MlAf^fF!fZbSwiB=$9>l!*0_UJh)HiO4j^F=tY}S>znLrKm4gtt-?84NZ z1w;Jg{~lWYFRGeQY}#2kU=paPZ93(0i!`i@6GzB60SqN?Ne)3K8b}MfwR$vDm<{@3 zmI_y8yGzD1G081omW+e_Do?9f4eGxASFJqCefhi%2@4( z7PAoI%0p|0kADK-e8QA;x)0x@=VvWNJbybfYFO^>Q1=^g5`ps(tFs9XJ1X5Z)*U|5 zK6W1Raqp9((iE6?0Ew>GzFgz-A6Yc zphTO`kN|IFJJ!VeL;fc#4pspTFkwr8UGwLESPD;8Dm z7?%Z@%-fWtUT6`CS=Gx`sq&05Pjy`PKdY;+65`H;LqdI-h9P67sFDZP)wR-OML+~H z?ahRcZ0(p0VmeaQMchFc8Ad7yOo#=rR2`4eywD*teZZ_O^b^f)%Ns0U?#H)<;A1&( z5k!6Gd9{zx;ub~z;b1SJgsQqp>u7_$h@Ga2Z$phc4s)85lpRK@;69qTkii^>T4sB6(1r{at$~0gPZDIn_p;m_VDZl3_u!3qY zCt!w~19Qe|i`&QVzW%pE* zKpPpnY>-UO7OQ*b*MdX3n8#(O5^_G2r{q-)){j17sevfUU)ZUu!7w%XJ)%UE_i%9+ z^gjy>zgGr^Z3Q}?2yA*60$a#_(`I#vm{a?TYXWI(clJ&!tZnP*;S>|tZ}P1!#Ro5~ zqY|{HgZLBiVM^%hn}#xrU2caqr(daxF{;;Z4O$kxE5~1jF?gsw%}g!(xVh0QOb9y| zPAWESH2RSV2|}tC=);w4V9q|aIVQ811vLU1iRtYLr}o_t_9_Es!Oo^WYz5v#xJpvg zO*l_fbb;>R-LZf!&SCVuZ359v!lyb)TC*9S%xDN(QB_4HfjHzP20ONm*1Fm~G_RY| z^~N5TU*B{(u|5D0&@-Mqm4q!8AbZ9A_r4# z_*owHA#CVPwPr7YZHXldKXm-x&?0;ZFaLo^yzJ`~$@dd$ik`yhV#UL*yz+wrgV}Nl zZ(4Xh!1pVNgm|%gGlzI7etNY3H|9oLnI~?sJ#}G{0X=3@*ZcmZh|p3U>Y1C#J1(x1 zR8lo+{e82Iem1_)p^p-o1O{3)3%ZYRy|a!Ou%2PCO1dHMJltD{p!Ru8kSaozJgi`b zy{?Lu@Nk#xFO)q;rifjXlqb|(J6ixR062#0sR8>LKeSfI+Jlf=(E>6cCD#_OuG1{E zcd2T}sHO-_UrNYSS(U2c`$FL$scMh1L1sM&YFv2k=74bLaSI#nDE+W<;9)$qxu5~^ zfhER3(?FX9ItZT5GaU4quBq`mXn0ENe&Cd_KflFRG8D5FJXK(} z4wkBWWmf6)f`gj``hm^%K4D9E%CueC;wBXkzEu4O;S10gseULS?pix zb!>s0Tzf{RqOjKP{fVI!SmSR2y_5a}6^*e}RC$@UjekNDfYWd7pI+`3sVNx4(#?M>l5TO(!0jirE(X@emKV^i7UFAP8gY0@6PdcZ8JbK2Uc^w_iyr z84kISh7oLX9Lza{6rq)LjJ0UK3y7|Ju&M2v5b}JJk**y4n znX(C|kBLb8f9`REL}WDQH|6GK1iIcU{}Cj19%WVJ4IQuEjGlvNd0QdzJyE^ce-8Xn zmW#dN4Zn(*BFpu>5gqA$y^6R(Oa0*yQ^*6oikL!+aMT3gBQH}@H&GJ%zwf_iVdB9p zWu2{hYD1?3);k}5+xbnl&R08;MQ^X8Xc~d1{S&O|bil{utVLQ?%U&l;$>WC<7pG)m ztw4e-{+520fZ@ndX2U@b$3$hUZR6q}TF`tZ;v?D6X?qsIMEIH&B0&nJ{&e~_*eORP zLC$}0vVz3lAm;|(FYn-DIklxi04x1xCh#d9+H$!iS69u7{5`;{ulTV_F5V0g0KmTB z)ws>kA0>Q$-v*r5$_8!jIRMxv(%)DBr2lQ=jre&7GjXPi5VGQcJ(!=cN~FI80uex& z^8{svr@;ri{fD?*u*-VG^gF|%9KhCa(&w#UM zz}bI-(r3^HGwcsD><|A9`@@oN_I~}D3t)x`Vk(xC|CgB{W}r3=P>DOo{hN0+voeBdEjbo)iv&-3QY_j3DP5w+)p+t1&n zF-xB=Ju!dY=@&0%Cs_IIjo(X*lJ2&uJ8}Fgs21)8%XeLgzd22_a&EGdNj<; z0UzGIPuzUjC$2yjexg!4qjNjE3 zpA48rM>>5wrX4B$5+I<94_s{YXjb%*Nn*ZZOR+zGA}rkUp#6Z7Uk-%w7nb7Be&V0o zvb-HJNw{!qHU38>U*UDYSLk(f*+wYfD!-SA5}DCG}FAK8RIe=az~aKx8D`k?(wIiOLW zex!2&``j(|wvMxNy!5A6g}S2^`wZ_+uSwScS>ZPGXQ6+GZz^Dn zT(ZSt1wwPM)=K*GLGcB{k!eTjz8jDg*21pE0OG{;)2qUo^EnF;(`(XOz-_5tdi_3O z7P^II+Ilm7!$u2LxaQzaaF_73v+TpWVw2Vz#0?uART0yVbTQzz)DNNEz6bJ_)2qTk zvn?yz4@|E~s{m7Bso|Xwz*Gn-rlS46Ti03EPAjIoSdQ44wQF*#E?d`G(f)4wk7HwVCi|CZ{r!Q<*+f zF_Tl7BB+?jsZ0@6{BKIs-j@{)0JNZ$ezQ{;b|-G4o>)%6j>WGt4i#4jbzuB_e;d!_ z>~aBB9$*AIL{}u)Ft+%;$Qvi)@_sQIe;-z%M{4~e2_TI-AA6>50d0RDjL_h6zgL+6 zQiyHwp<*gPRkOojo~Ymgz~DWoryIsg1*tguK4m{mM~BxmO;><3>m)IKHgOc}b)COQ z=sNVZjeO^nneDR-fds7vnb=%qt3lqfbIMkOB-uq}tHD<78X;&kh?MjyTMcGgJmG;> zgJ-boC6{eXX}5yoDmMx*NQAx(BrRzcFTob{d}}bhOaeXM&ND5@%I15RIY`iauYpAL zRyN<0eANn?@11*31C84!#(Fy^`rV^w04GR&OqF)(b{hcw#IZ`nlr-RUtJ-#4p>Ao3)882JonPgdj9^TjKVS(-&I;5qD z)pIuw9zG=8Y|!Rg{LZstbgsUw1m*Y7Cbpr=eLM7Fr@tML4Xg9n`p2_yKH86mGAM9t zteO1SPV-tnop6DD^p&7xFM4Q zsKBYXYys&x?5n)@bW4PWjj8nMGK9lpxMJ{WrDMfoPgB%Z(IUt3Gr_EW&eprDvA*nJ z&IURMBa-*V6HHHoey!CReSaiIHUa=)NA_G*!1+M|IoEhK2*V~qpO=kQ3j z6JaFQWrj?MI|;}n7EKK3Grd^2xdTJ9n|LHndS#qR9<@m}O3)43W7Hj!Ink|bq!=u$ zrf&r3e}9D(fJQa~sPRD=*PCY%S1|Z8EH@!`q9p@#ZWJ(5bGlkHB%=!Fo=XFBpTpYF zU5tX$4r9Ns6f#osNB$Vew-4-x>e3ZyEv*37PXfIF|D7zq+`rr4_XLHTiQLGd?$_z) zn1;8@-t&2H^&BGSOE)i~#MnSOeH|iKK(;KRTql;$!9O(3hiuvO<~os9JMB}K(gARA zS={)Q3l)mCHXO4LDjy0TDTc~l4rJ0dmG0V_2e1$iM(Hpzz>t#Jr}dfAbf0HDhnV!< z7oA`?XC$Q+eGS0>DcQoYH$M7f`~;izZ2IvNvz|%W+TZF~eofuG#u0RPw8lfSc#f+kx|y6f8&q($|3m^Gtr+eEgE^oG zdj7!br&0c61{BXLutCKyG0++17odZZaI!!$=X7pyK8vnL%n1-z)sVS5bhsBu$oudT zi5U0zHzw?FdR$3AyK8Gj_v=u+dS2c^*QM|I$Q2&RD)2Sr%8d}$x~5#;XMPori!4@n zP*f1cc}WK$u6LS3hT=b!*&~w|A=Z$SHcA&CuvM*8h3fMP1OeT#L|X?@F5^B%#B#5k zo2iW%gO`I|zfGwmiGI@{u8KND?@hqH8lW&7WEK5BX_h(tDR~n#D#Z8z%%$|^NR64= zgWi7A=Sr)&h!Clc8|~kYnHa0XL=21t#BIMI11%@}sE<%hN&V%ju)!T86GLGh^)DVo z`%E0c%@We!;)5+Ngq4CQ0AR|yP$r@>;qqphg$_+aRNa2(jsoS$Yig50BzP_WO5lFS z<>$+b-8?y)@vWfC;Fs$680mIpI9+l&UTwh zHfQfNp?#laRy=-dIhAPXmo5TW!b<9RNoT)1Hrod-8`CSdL{MLZ%Ka0z2nz>5E9z5m zlzQ{d%PN{1JmJBy+xUcTQ&CRmua7tbsXVeGvApt;o`e=Cl?S>4{O%^Wq=jDLod?HN zwpd_BmUR*p;wN<)?AVDb+JZ+r8Fra%Bg|+CjgR3-DiEe4e3$C#T%olYXK$YKBjsVz zS1-391_K1OwW+UEBB8N7S=s5>L5e#T*DQ?jmnY^=yf-VovDqLsy`|Oq`aj>%Jmrp7 z%^xxH9+0npV0MX0Hi~InA${2~(&c-fb{bi9XR>`|)bn|WioKuOS5EQ3-0D{E!xMZi?uDFuVF-D zL>4htNUa!>K}w!-KX+E@Q5{qjQCg)jCIAgx5e~n228Yb!1#ZrCr|aml{OKbPQe)Uu zVMY|?ce{(jB`(!i$*(>S>`^VxcS(neT?IQ|2p%ax$epfezDeJ#!BfSokh}ohL3JEl7|*o}&cu4o@C`B(z{fwqNXU}er=iKD$NBBN}5^nLLtU#CBr`sR#( zMe)^Q^L%XZ_|9>S?`O?kuzS{=FIwCeKGPn`AtI)pGD-FL%Or(X2J&2Fk*I~3a24tFt|HvuG+419puw>ms=$KyaakxOz@r- zAB8o(aO0Qc)x)3l3IHM{Mo_GXvM!`v!2UpnQH}XTe{bbKUKkXble_nQHDMy-nm}p9= zjD?ZC^2=_fmrgKrC>;`401sR(;AKTDu^3qdc^KO<$Hw%lf0aVTlG&!`1g~N?1JWi) z;Ft+GEaQWR7Ed{Mrwpe$ra@`z}#%o+@GU2e*>c`_XQm@bY)qE3OuN2tsZL; z9VZ{`%=f3(I>J8urd4506=pv*%k&uE|Fu?v8xX^gN^-mRAqnhWtQOq__a zc62$dfXRkbq9T*YSgYWnH!2m6>zf>c&UOToN#3^t*}?8D=eS1@4vwv1h^Q4W^9#!M zt6Q5l&;nr}m;$rIf7gpoOMMe|`H-ok$PY)S+WJr~0IrE!5(!?U&X&Rr2qIf63g@hm zQeWb;M~nu~Y3>S})Q~Tg>|xNHP&~}6Hh!JP z!`J}cPS@fk1{(s~{17b{OY8SnErHtrcy@V}0jn7Ct7Z%WPoW0wzuRT?7c?Alr2jLNYr)15QL z-uY3x&|um4SLWB02U%W^>`nLUi0$pm|7DXYrE>ZDy29?)%V-9`d-`lAc1`recfN@B zKHWjK31kIGisM;V+xvaS$p#M&=~A*nN29wRuu!ak>zw9^Kb=bvrVLKWBKF6%q58MD z3L1$gQsj-P<<&5LoulJ8ksgaW|1mC9Mn`c&ZyO~QiOIt83p9TB7y&-kPPi|kD#owU zCb|_SXe%3^9`zkq3DdTGg{>(C#~UhE-J%^kRx2O2q6c-=@b!cS){AjkgMe$!5^p@Q z)1^Q$GV-C+jB}=6KA+Hewex40rs3la8Qjr=@zqfLGO>=~;{&TyY_zmYDlmc(dzfz(~gSw?uANf3sxZKq$auUmy;yUSeHw=I~;x9NIou# zGRT()$N)zI#@DEuT9E!uVmBh=Kj4v=BpfC=o3VPLl%X;~v$n(9w48~kk`$*!R|lPc z1QS(TW^P)N+$-|;=BxFycjB-%IN1_JiW<%^|FsW^1z@h_bZkg4Q6XO!b>BqPmWa!L z-`^b+_D^>%Pc2%7$eu%qbYzbK7ksLbCdA#m|*bzjWr~iY96JX!{+iG=y z$=ab7Ctf%tYT`Q`+*C-v+mq$D1c3}XRrix`){n+=&+%c{sn)N3vv%y2_ZyDDPSt%q zl=-7E(yrK2T{AN4Ysi-Cc*Pp*eAQ#BmDsNP7E!>-@lS6evnOmoLuzBe|$LZNWqX3E|v`8b_=(wp;-^Otina@{3pT7a{RIRT%w6X z??nL{sHFFG*6big(`OAuolXSo1DU*#vI)m(AD}rNSd%feW$fuuyyw{bfUf66O)R;c zz>num|K{w1c%H~JHg|(mU*XjH#iD4Fa!su%^|5h?TnVV^rTkq8>nTF5c2u#?LGP*k zGUP_X2XSiQvT|$YTF7fptbt?Sk}a8R^LR=bc%u&J5L+Yea#CHqTps4*yAc`dTW3LM z^E`HWC1+W_Z9+K+Ub%>2w}Bqmm%DTs5=07US|2+9@T9Ojpr@ms8IO|BH|KgaKRM>m z7SAn3g>a^xoh-TQ>94=s5~?V}{q4R(xQOXT;SV0b0vZXJReNSh8N3|LBfn|bx>z?} zru%Uu#H$8eVP#P#U}3DMQ;A8Xm)GUh5QfSb%6^f7j6i&JI|z(nS%zp^B@Vsgn0?b; zi0||d4s~m=$`2oXSjn9O7*om8`HhgTdG@9Mln{zyr2w%`qNf70*|YKD0(p)|U&H^k zIP15!SWtUe-6ZZBPj<91&bpL#+{X+&wrr;EHeC+jIKwFv9RKhZpDoK9cSy2n>+np2 zJPKLqhO%A9PYmPHgBUuE7!6zPS?Z1DVW?@%3FR1Kax7_l?vAf8gyS{rot*|GUG^D| z@uBBD239uipBkGaX1n-A$G1%ssCrlsqbL69iT0OxupM)SwPk zd9Cb@7L~zgpOViQ49n=&vkZ>N5Y5voJH**yuI~K{y~G$|MaPBKH6j|^%KH{ZEwG#s zGpN*0^R|naHCk_WaF%4yoNiUNJ$DY|c8G##NM3IBLRCgSAQIFk3i0Sof8@p9$w8(3 zuEQ6DCiIsTOB)6~YA8)UZ*L1ESQO_=AYA%Fs~c+2YaK_{w7Qi7-<0d#3~J3xO5|sV zc!^_1C);Os0wKHh*M$tO&37JD2jesvZ9Q<3e=&j-;($pgn>J0+bhm`lFX<+f4m>u^ zPt~ush7(OP*;#`v*h8sq*8W3?NUqhj0&ahQwEkSxQjnj3Et>KmuPxEa!$Lan!)+fx z0-AtZ^XRHCR7D%66x|rMi=1!IIyqiG?jKY+2ORBM#+a_7KRhHBtbdEYy#6huLGdJ_ z_e2!d+ts-=)JCx2I9UM9E z*3q@M{Z3AO_Z{_-pa*FB6NQ@nl8P5z)RMGsU|a9V*JHsC!4EPtYZWSvN-CbosBLDt zU|Z|u>$~fJ9tlEbfPYl%=O~=9r=WP{fNoT8Q+~<0Uu(x+r!)_QXEd9aFgHRJE%($O zl8l+|o>Y@8btV>S;7O{Yc?R`HIgZj+Vn}KG?n#gec6L~K?4|w~8jbbpd!Eod5QhF% zaBGKwzu}VeosDmX4{e;&#wa42woiXwBZ4^e&8&9aL1fdOkKflwA^As`g9!d_wIaxW zVdOQH^_uSWn(j~K{p&T|e^TDRUemn}6j`t7{vWUDe%Q35{4*E8I%q`uT;spNkJdpW z>qxY9B-%O>?GMb{I%woi>burKBY#rgweGe2k9#fap31tXvW}Tw$IP$8Ti4;Of6!a3 zW9HY1I3Vjp9DhRTvQEVDCzLMh@YZ#B>pHx39p1VQZ(XMhTc-?Lrwsd#$^YeyFcr@KVRb^bkJ2{uPBx)03@&3uvdmqJ7}(L*#%lq`Yh@*l=^Y)u(E(*I4P~& z(lyT{pGPZbuLPh6$IdE`SH7X_@|^g)!Uh$3_BnZNZSn=y`IYn$pQb&qBcU9EOw#dc z?kYKHjXqiqdR$HuF+q#+X0~t>owWyQZ7|;IU}o3Cn+7^DJZR3yu_M{Yqk63jl)~Fw zQL182W_BqWehZ2^HUD~1XOT3&EfcbFAyQV1dr+M;{Jl zQ63dEMmTM=cR#-I(qgDJbqKz#+vVFd^~a4iQw+iZMNn;2%Pak*-+ z&TfjebeG!2J`LvrssTgLsJq*TlopU^VrEmCTQ+h-wm{13?Fk~s6X6^9g0~odBqwor z(5APbAPBE~68E#Nr*j#34l`cHoAR*r^GvI)4aMm3_{dSEzqmvIay0ATkfV>U zYVwhzI}6AShEmEMky)bUIdHmfVALQ3}V!Zd#n%giyUaXP< zO2d74jm1;B`49SSZUBVp3R6_R)h)<&<%>CdBEOo3`um7{pU;SVXq?YB0K~a_t7a#} z+cf_G@-yb)0EO_!Tq4zxl6{0U=KVi_5Q~jQPZDJBqrasAh@f)l@C5NZZ1oy$Bt)JJ zC!ry2K>-yshVPHKvRj{_AKX=dN;d6+ERJz0zk|ku+l_qHG{;Lor#&+|?((RVt&Qva zjO-v}?Ui97=py?j6fvCwQ;y`dZ{34^LTB1 zzMLKjuNFhII`Y~*BQR0{aL^3C6f}eX%d8pgeAWyp<{XB38|!V)(0ru_C=NR9^VIRs zXNkIZGpr}hNg#LKfU7ZE?ejG8XAUL-tgGI@xt)kzH!9V%+lYB3_%qKEweF(7C4(7- zahJu~+vyY4pOLdME~`i!FP|@oM9{j>efU~?-fG8LnKhIT6CX)KReRdcV-o!EXg=I> zIH;g@HE9s{ONI|fC(j&*umMV4S19gCXbOAKyiaN-Jt{rw1JXSw|*Yba;lW z=Pv(7wFBpD6fTEN#%Y6nlCTM6*WsM&nmlv!g=DO#nAFl zP9X|y4FD}^4YIPl`GJwVO12JNsyQLfkoz+)5WW`qw+iZ0J^?ius_0-Od$*u;=?MUV z25_gbqf5ow$L4F08UEG^9tX88B~vtQn+-o>oGwWWdi6y_Hs*Z?iI(l{B5U7hZk#l| zlhGLKLh$2k(R%@O!DBifzRU5R7~_638&JonEgG~{CPS5~KGp-V^gk-BxAYGSb}RV- zOA{Ni;M@L#THP>g6d+s^0Q8%ViWL69opXXHSI=`fs!o8`z%V|POS;>}r`O0bz@H6q z?9>=kDCf|pxs)IrgS#Ay;;@s8i}Tm|0c^^{9Q2nrPG2qI^*Y%C`Vap8I}R= zx#*z96!3;gPpB|rzktj!)9aP+7%+L~{xTYYKY2;?(cjTH6MQtz_+Lij7^(?48nRZh z+W@Biw}ROZC$}m(p+he5m|9xaumOPN^&#h#Cw6M$d^)Oc%=Wz^@=$yJ6v&c2vi+rJrxf?ch#!PMAT`GF%B>ian0NlUDXpvG0G;Fem(e-JHJ^ZV$&0PY z*ObdW0dnpyk>dP_cnqLP&C)^f#awC!*NaRW;U_Y4^D?z0?PSi4kw{$0V zPz<5MiOEaieh?rY1P;w7$1JK{nQXs!ixJcYh1}|?mtCU*U~{2$aCg9JvuAK`YCGtB zY-+G{o6t^%F-EwhLRup)I#RVfJBH&R+(Mt0eFBWZtb$^XN*))IfVTC94}(U&ZD@cD zU!IyFBXtIAnK?P3Mw-Ldi+(> zxY7IjbqyAy(GzabW*Gbr{tMvGw?UmN+Y1^f1JUOL8v zg-0nVqO4ppxv7@rx0;GqJ>#TALwO;L6=A2Ynx}K+Jo!kN?NnVrS8c|Hlw+CJ0poK; zYg=^_YZ)UsPY|m~t7H2jrA@nzljlx3R*9uA$NRMx6z~?gBduuWbshRpz>4p9Fr#>+ zPma!$BBRS|h{?D-TX*LHo)~Z~IuKZ+lZ}$8Oi$(D~26FeBWh z>Y=3rbzu5H`}V&~dO_#AE^IH0kQf)$;W%~RBg6hW@X1`b!=tFRiAaL5U0A7gtW@9_ zqvA=tH9-JEr576objgr96?1Zn&Q;F7hQ*T%JO_;iMT32CwE+y=<)F`2x9HuE5`08m zFVrBg{yy=pjGo;)W($D?31#?9RN_9OWxU_V5_3Rb;@_SPmnYfdxMkeZ&ho%pmBd7= z)60Se%#I^yB>~B$*WAp7hMq_nLiDlnO zD#kqI9Y=!CC3jjUot{IQOQn+MF~hW^x{BAiRkN>&-B1}tcHQ!?$9$(dPCeJl@m*|}lKl?Ip7^A^4bb)`WqWbxpAOj2mbjsq?;Z@= z`ue2+9;Ldd3>bA}nW&65$YOXRs5M2S`PE7k2`e7u2j#=0C%&c--lNc4E|n`i=h@BW zHj?)%Zz{{dg;Afyu~2rb#7sk_}bqXbOmqYTWO<=_ylsJSTI;BlPr z%3)zl7uuO$(sS;U8Lpe5<+G^mt7K1YpAfDMm7h4M8T$VO!CoZA&lFr?0@^RnxHef} zO8(U6)f>366M5AK?yKGQXD;)eH<$EPbRF7WI}DVXCCNcpts(5u34b@%RJK9s&t$;c zweZ1};R~9gC;6d^Y(u~C4|M($sCuMw0s$EWg^ya{dLTTCA>s;X`F)BXTFCH*&JsbC zJp-#=s$YyZn=4*yp~>(*MiNu7!KZngp#VvXpMe0n|@(Fg z{u^?glmtVZ8fH5vD>KFeV8h;P zQC@-|QYi^F>^ATEH+bp(+oIslG+v_qwZQQ}ULQ2KkMSALb#3c=Wel|qnu>*<3)t(= zByp&97PfwGZ#Ni1xz+7T)$u8~(5=v2~XEHwxxNd=5lLbmAcSPd2aHwqj$98c>?QOkCttmL(w6gE(QWML}<_~f+y z2A>>}4`oMk$5Qx_tkx?@ek6;}E&BldBWV33t2(~SR(y?uqPC^zEuCWRwm%!N#|v=i zuxAWIiX?CT_FTYHEIa7khGLs6w*P=y9lhTu@^bZHwP)zs$`SODW@$EZVI~>E=gVZ( zlMLkn<=MhZG9b(}gg4aH7Cs4~>txu%mRk9|hZF4d095e#`K3|`&zeF>uMa020M}Ur zOq_;WP+4?>_@3ikED@BRo8LNa>ob!{{B^_D*}8)PeZTpvhJldE{BMO+fQ92j9DhnQ zURm>}glIDwfwsKHf=d$B3f$itDHe|DAwH+^_zAxNcL@X#I+Axhwx(=5wDi3XmAPjL zOo^`Tt?Y}brhLvahrH6P1hOMmiJ#D!!OUnZ7q=dgNRleUJ>w+~<%Igtt_fEwacqH( za{@`=BH)!J3P=Y&we}x&AO_T>7JIpMkc_i(E&Ye=*hMp!86YyZRjQs`=4Y3YDK@sC zp?WE3sQ%X?9zX-yetUSxvv_3}nxK?sl$r5|5X(@aXV zZ8Z9?Mt!KiJzTgbY%2x*&aKQlOy`7Wc*dshbT4^EqrJb=x$Sv3V^bJ7ipc;+Cp_<> z!EZ><=!}11sKFbWIi%_ zbo=^u2hZ->{_dNo1FbIKt{qVPuKiKs-qs&lPn=L$qU9l6vrhJRR603!RjN@}P-zRZ zzO!S9^eUVF((D{#XV&}4mdNUoqlgzTIFnve&9;_%pjWOKcrz*Z^eZCJBPGGtyr&v$ zEJYv}uc&vNE8jiY_kFV4jhl}5wU3?5I~pbuGG)?Kq#<^5s>KdO$K@PB{F#_8 zh3!zsmnPv$8TF2j!(@%eUSHF`VyAY#9<@b7sQB2WpIO^~QDPS+nKs47#~Vr_f-ADG zHX3bKM4L;pypl{CQStF07~%|_mnWP#*Cw|WVlZa>dh&31qM-sZxZ-@yWcJyp2#Xof zlTRLh6L;||m)mp0Z=d9=?v#U*En7-oZ$n%ix)A4eJ<7^t3-qX)<5_0xvn)H)ru&B> z-*q@Z=sj)Pj~*@GZ$Jq%;$Lf@dq&(DEi1Fo^O_m^XgRteRwl^BLGtWb)_Z(&WY9Si z$;{uD?>EK@2e_DU)X#RS$d6yUXT0;5qOV*|^<n5@R};-4i`o|%f&qc*3y>_A@d z&WXaG3GYSM*zEcPS8!rqLBhIguOf@W2iGgkO(c#IxW!bmZ^e?{+ znIlK)v?W?(*olooQ9$w(LW1uN!~)a`q9H zHXET|(FWIFx+Euf?{7nW<2(G7x3R8#)|JoyUCO8Zr(cPmxd7G`(HG6ax*}Tlg4VsD z|LAnBdqL}7(0T~A9)hihU?AdK55d+$u=V`Uf6T_M=YQ7oKkNCQ_2lz<@;PU6J^8$z zeEzeosP*LY`p(yvH`>;BzSeiX{$!(fedlX^+x^Q$5bI&rdf2rdcCCk9>tWY=*!6#D z&Cg*%Q;e0qTiHyOT}QGiewdUlq%|JsTd}Z8PH>e-cezl;sl^}mgfd%44JARh_jt(d z8fc4T+gFYrv7Uh%yH@!qYQ>yzZCP!6RShodw<1&{N}t*nrG4(8qYO$mX!?i}dZ-ism7@dHlc0Di&TkjVJLtYz zQjo>eEn#ToV;7_#1kS#n(Xbu4EccMHG3DgfjxIvfJGQ>_Ki;rKEh19-$*$=Xr}S&{ zJT5l0zFoO?*Yso8e3kiwcc8LkYWk4l0@gsfD6nShgh<{ zB=JU8fVK9lLo7j`Gv%BaazXpXSI4O5{idSZVnq^wFyAeW=arlboTD@crOVt8`1`BM1gxs+vua7zDN|+NLo4^V$Ot1w z;8VZ#+5`okgTl~b4{o9apkE)8wO!m%0Ny_4Sr^ja-7IaK>(`oTAk8O7u)ud} z*~;ht6hvvP6NHU847Mf57CmfDtUD`71PuniCT;)x+c6+}ZQ2&6yrs)i)qN{d&w8Bq zicgKDQ$A#;L6Io0_>2U)D*j~})C08?mm0f3`S9|qV|vwpYM9&WnF6}bW9yyDCyv+f zxNN&b3#S4bHq{s{Sln^!e6k+vor*_{hAZK8{Z25?ivLUIxrT}yZ8+AhAO#ROp<%3P z*i-*`297~`!<>wc9JCG655D;<{D0d`2XhLc4IC;~#x|JJ_-1I*{>GpIemb@Obq-Pp zz~JlZC4IYWB(!#<|58Ns+zy2Uc=iNh@}pN+24~Zh{!i1FzjE}jLzrK9Zff+TdHztR z^@8K0(H)XR#}^@bOqb8v;&;t4D$AL2d=aFlJ^R%$>M@_R$aG%$g^d7T>k|l&PYu?- zSgUHffSloGLFF2+z?saX`sdM#MiPUp_v|#mADMxaQ(_Vy#8(Z`E?Fs2@(XmCvteT2 zO2T84`WsOX;+%FN^=8^{5Y7N=xfxYc5+Fj-^ALNaVm9}G|65Hn z+2Lsc6R*t49mO4{)pRi)JviCBX-~uZ`;0KZ0hXbxkgO?fXP;%Z~pm{7md@G7iwGEWO8+G#z!k529!<(nqjQxESplw(*i*H~P(%(gDpOr?#gtjo7?ilM36ZJ-b7GJPjB z5*S9qy^GtAMSkt*Isz2GdlyAb>Un6nO#5iXjU|3KtR*{7cxR}Kl9a(8hRV;|*Bj}g z#-#CbTC(GWcSc_wlkE}~F}!c+7!1y8R9Rc;jbz|LA-Tu=OI1LB+VrDpSUuxFdP;Uf zN$({qy}FOr&W8cCU?qta8yE?+yvg6w@~458Pt)|R{H)n0RPotKr}7zI@i{3I-uRbE zr;v=LxX&8@ckTbDV|q(~mfz-<7AfWLtUbBZ5T#KEaL0DNotnZZz2!5>pw^94NhcIpkKTb{qO16SCL062EfP%J~hTFs@5A!pCeUJBYTTl@kp{ z`HqhXe?cE#?gjezWJy}n^iXk^o~&E-yrz3h5uiOnRKV-Q-t=CAZS<_A4Qw;Px|Uuj zs|mgu^CQ&c>as-yI@r1|=6k5$^<@iWMzHnOnC~Ird&`K7|7uKCQn$)z=YdGzN6weN zwO;t_JL)L?g-6#|16u$6=*$+ij@|q~NuiK=4L|IJ77VQ-(3hqqJhyuH*O+3jx3@$4 zoxQG}I*4{)wl<#{KjU>3dI(L_Zfic(e`d+})WHG=?Y947OlHs?p<>`wS=Pg?xdheh z`9cqc8r(o^tGy0ldFi*o!(M%2X?p1mM$*!=r*ET=A7d#r8yP6gp1zZD{1{xJ*-%<~ zRs09!>hri}fS7JLIevJl_rS>pw{g+>%qyY9g;#vCQexBAo+jG z`yO(vWF5Ek6(G?%ZfPC2v<{kF2TlGzA*}zuty?+q&3D^={pS0<-+a6AAOHOE@{Xr= zrkjxJ|MB~OAAR@dUn7Q3eAjy8PyYSKsJ`t_?aGi_+V}nE^ZzkoXxme}fe1B|FP-DI z(FJf7BL#HQ*8g;if1Kj_2{1xIU1j5!ZuOS~JD=M59~dQ;e(`Y|Zhs?G-0*IPeb}E) z2Dnm$#<${*k);Md3V-Q79(?nidI$2G`!(Nx=|=W?f)Ug6k=`!01%(mSsU_t~QJQVp43+LbOWZUj-{2^he>tC9XZ@yatucj>Y^v_>8$vH4-TMya{ z2z_bd|M3*801M3xU%m4RT8(0ffS9FWs#Tbts%5>wfcZ{1 zLP%?9?*-~@;jmU=y>00s3zZ{D$9NoSq@t#bOjC@6SbE@ctJfNBFcPF}U&Smg-v2?= ze6L$=zo{L~J0~(oyi2)qs%NP~4SO{Ua1~nHmG+zF1n2wVVG;y%U!asMkzUvoY<{!* zR0BUhreaqgHj<=4?NAH9s5zmXTfI<)pKsEurbMLHhgo@UTC8Q;x?h4BZn+*p5I4{o zz1gb*sD_*9D{d7tO{oojG42j8CSEN1dgnJX5@WO`4tJ=s=kR5t+{<2YTECfzvpRXS z1fN+qI_g;`YiQNANcMNHWI8p`^@qB!7LF;IXW*+d*$pR!_4^W}ak%MZ z`>uRqn&6=e6_pgsng?mpjy*Ke{ph^X9#L2@1p&YK_+}`R49hlcDpVF9v0u0?nH19$ z(hZXP+Z|Kg=_;j#wG+4@kAvlbW3@f!E%rrH`d^ zEBkPI{5u@*4jRw48;B16(Jx@cyLpKqgWh4V@=RDHgEdAf+can!U^Ccaz-l#&IImo( zeF3tzVuP(+9dV8M@huHxaP)?0*5h|NwQzdYUR-!%=mo!~G5=9_JE~&Y7T(%os4jZ5 zYp$x2_08^ZyDpvSkGB>8#psq)je#6@gE>pPLxR_gDG6q47?K7o5P!;!p^q8Us0ys- zl%1v$?+7Xl(Z=e)Y2E-1naK0vF^xS|dbgJ%M?E%C_SRBGC0d&D3QYr9#H7*m<5M5* z)|X>won;Ot!s)kWU%i?0k-w_1F_*Z;>@%)U!mKo28{-GB$qoa<)wT@+;xS!GHvVVL z3%Uz%6b31Ai#^+IF@?M;oP8a1N`<% z$il;uTespkO|f>5U9x6wRRXSNn0aV}P}~Geog6JQBpo`}oxhqUILRjD7W>r+`!@+C zHA_=pMs)%$Gs^s_$tfs(*44w->m3WPW`(G74FcZ>@UveU9D2DJBiE?{7t6d~U{u_h z{pP-m4_0^4N&g7S;P^q1wCPW8CZ>JP__arthBkY0XSVkSF5o|ANqBC|DZp4N;+F5D zc^u9gtQoI7*qq}4+jXZ=wVYnzRx%&$nC3p#DE@ z_#u1Lf{yT&*cLGX? z+9-0f?G=k6W$_mH;A>T&b%Oo#d2@P0q^Ig+33qf_`~0ZAbV8{WFDrOJ;vyP-5V0Q$ zgrKeNcOjUnJWqS+g+Izms<6tTPUacD{2$FRmi6%3O(kI^5#92H%sK!Ax00s z=28(!${|Od__UqXzj$I1!}bUS8aeuOTT!>B=JovQiPe3Y3ABJw*MfpDnP6U!J{hPu zMm=CN+CH5gw28mwfDoSim6tyt(9D$EHZ7gTFr&>Fvas-;fC?0j%(Ax1}{v-lt{FudKwOhRl@ zgxfZHL=N5O+%cXRY|bvvZ+)e?uR-qGNs8Z~t(3uPH;Vos|FxYa;ymN_Sw{G78Bf(i zwt4DZX~T}Gd2)=VRFBPoWqCj!MP2~!Oszx3KJeqavyD&glTo%X#7yQjrDjH0U^Mjx zF`=0b7a{ET+RceuHW-9|SQ@bM6fxU^6e1AW=bsoyD;k`>NxMA1I;r()a*VtvN@C9W z-qb2)BqfdV%|_J8n&b3Nt?}RJS8W>H+cIipE6tR`1|YFPozv!c5RPaj?UD1U%W(vunw z#i37`C9ky_c5M=n{N_?e)@Lq&z{|qt>8CF45^WwEAry6&C`Z+O@WO}J&FVxT{a*+b zLqphB_@{Tb&JC9l(&8hAZwHs{QDi@BQ748dHI+|*)s_b*uHDuazsP?6D2Wt&?iHWO zU4$gt=3Pf0A;(Y!crNVcA1g^*g}G`R*P-PeldO|;<%TOtIU@A{@I_Nqd6cXtxDR^y zXyq=#YT;r*(M_28Lu$T>{qB}k=G@o;OWH8}4fce~pw?ppU8n?@s~aAfKu8qP=uE_Z z!+A{@@%fEC`~}}ay4)e{1|7j#%tb#|%AudS8^X61v+&+3bt149wl!Gz*roj^soD;( zKAk)9Awf?p*}>c;r=wz^A@P9HhZ5zHXYYd=Oz_xVp6z@LXjs zvE`K(T?V3s-VhV)bGELghfL2j8A@QKuiYIGtE)I~wc)q3a@@mnKx`UwDwf-F>H%%= zZ`3Fx1Z>mI9kp-R2V+}e6A~xSJ|DCVsdT=6zw;K_)4H1;(PWs^WTm%+ahgoij|m4{ zoxWCipl85SQl(m#UNJ?er9cc`_-q~(T2Y$qO{rK6)(U1+TZ!t}zWche+T(l>EY-j8+yE3MIx-+I9ua zuO)Z^UgStBw(hC5r1TTLBMQJ{X(a5t#I$45ji3y@00g1!t(9k~357X{Tg&(M>$eF? zude70t8G1$8Wxz@b1-Q&5W5`f@5C7Q7n0*Fg{4p9hgPIgqjSU^>o`RHdX#t}MO31= z$+Pej*5sTREV%G=2302NP<68IVz8jPxM?bctlB(R9O>JoQ{6I3Xonqn>_*ryzj|%K zu**Q+t!~Yq;*qRAbNdbVM+p(ywUZwULTT+P2##Z;FQv`75O1%IyD~wV>`y@lPf{j{ zO-;>ygSfqVw505)=H81Cn~M2Ft6S5P`PK8%2x=;h{`A!?v_#A5NVaIPmv;Wrsq`l(*Adl9S2x}x3ENim5b z-4;&i+x^E1S;GgVBiFLM9a@=08V`*TGyQKMdM zZ0iS}wV89HIY`oERSEKRFXrX-1s(L28(6Rv8>7R$sX9ogK{anSv+*;b9Ggr!O;GlZ zM^~F!g~TlJyEiC%oAZEo_o}qCS*+pWHV|EQCNBmWcX_^#^^qQ&5F>fdr(|EfFf8VR ztsanKxDJZJWYB3j5M5X7iBnl7&1=B9aAwU(4qJ4{b`ehZR9QvI0D)x~m}y)Cn^eXW z{}*dd8Zj?#!FCA=Ppbx4%aFWf!&j}^D$!k~Uee`pEx+kqf}_cj-+r=j^Y&O){1ssR zI-}1d^5ks$Bl{{dx7<|9?6RMyoFY}!%Jiy`-QQMlU$7U2q7}=`jWje7Y1Cdc-Mae? z6|!h1rK@=%*~@r)(EC{2Mmcowxj|<%d{ud0`tAp2j;xPAbslOT@EkN?O~u){<-ZMIT#3E!T&PNtf78t^ywWt*J4%8gJUzk;c$DKh2INEsPlq{;J~I z43FK5u5Xg?9HIGGL7pm=EIrrnOe)ZN9Cgk9ADo%iz|uCRmgYvxa!>RR?Km5sv)u>A zE9-mUZot_rJa{V|LrdIbmtbPLm0|dJ*MKE?r+zzhByBm$sBnf&hC$SWYrMm8L)x~b z8C6b`PKt2ydb zbKI-OWpHpf7xQs{boijSS(K8ex9%$07aOK6Kyc4YFx`aG$REPF4Omfaj*#JeU@pJ1qiHpw{8Pb&$lv$h+OnnB7Pu$a^3MI89G{_`an?1lcK zWx!a04G=-{ClT!bB$DNI-AlujPn<-l_ox*I=U3{EAWBAq$AP!#Yy~;*D_x$1ESK#h zDbP>R*w5b?Oe3u}gNifKc~WhkFC>ixcBc*;Q%4z3kd8y&ezKp8Azy>cfoVMtUdWj= ztG0iTuFUEUNWyL+bt0QVYUr1DUR&a1Dj*$NxL`ZBl>Zj3i9qAsS{^hng3zFt>_@j` zn}Bh@h7$q{Fl+B424V#_N8s|p>ioixU1q(}*mABM9&GpbS3Gi{zV`ZD>{4Zp1=SYg zm9Hl$f>>T@#pE<#9~Mcp@d&>*d>^uE2fXs}Xm%QJyG{NJT3dGB#b-on!;Gif(+9lR zfR4gunz~g&XI`36adDTXE0d_Kw6Ry+_5)z$+_{jhT}aL>!{{ifI=%wYy7Dg7&>*`T z$)F;l9z^%!6;(5l(GGbRJ0;v}&N6$^9*dSZkbn>|D5Q^^x;vj++}TYHX2~!Iab72f zZjrGx1sRus$*xIIX(yEGQDxC>ZLyqpBRK{a&uW2+Zsar58mhU;qXt+3Q(Bl|=L+r0 zz}2@Kgf~KEX!Da?qZoOPG_In@lP__%ShP6tm_?|j_y=82O4~bc>BdofOwKNJPlYAL zt2VO+J712&*fj_ZDCh5}!f^$zVy1k!YcurGhSL)6SKjy4VqFHs%>J!}!ubYj(O#D2 zsEV32sUI-6x4!KiE@C!Yxx-C<5TzkLv4|lfpPwjmdk{$O9CVd%Ydo+il^Tuc^`niZ-`>)o zD@r|Vs~S{p6Ckux!fa#C>ai2=O}S&spAt2oBrJ+iS`{%|IsI7vJzDHuc?QxTi(>2{ zw~L|6yDw)ZYH>aGnn5#5Q_}P9Om=}grVhMSbjVSZQCo;*zlbBD!cdO_-Dmr4FdY~HNWi`iV0qAV0 z0jtov+MIkVw_8zl-(8_OwB&!VD|+jzl-V~R4coF)$i3l(U73)8@ajI5WRjM8b9{&p zidu2;I4~iu@{~{|5O(35$z9b=HLnSXc@W*@^5(_{bzZ(EaJiDSQVictP1%2z8r@{X z>c})Z2u;xQsMR(6J+50}@9$LAB6*WZhO?DupNmhL70(WF`Y~8<>A@yLi^e}JQ}5#v zVHcI@D{Xq<&f-U}T?%zc?re={Fjp5U=11|n>xK{EOPUPDo8;r0PG=Y*e9}~AZVUXl zvboKZ*px6=MD_rxrMAKxXKyUM3G`u#lrW?ft8SFI`5<09(vJ0n}` z`#{R#W{qP;D>FXMaPxU3ovgqF0jc0K+8}cwuo9&;;heEljaw@-ht}MfMe1sV_8e0R zobOLMsb?Wwpu&FDWH^2aq#E5zuic&Rf-$dD&<3nV6NMQ9JyIrQ@&nwm_S~BSF*9pq zJ3*w&V6-Btm_C48ZOhSRFl6eyw>DN@4WM0^XVy^F7k#N+x`qTud);nOnk5?@(O(Ce z4a77hiv(fZ9xQF#3>!FJII{+oOYdX88d@w@lHShH zpYDDZiO`pXzsLVjw?X*0HsG-~jAHu<6{ClJ+LxkX>IE-Y!&roYbwCJpb}sF^No;U4 zI`N*FLT>frTTYi2P#TB>qrtF(AmAHr;kQgs<$0v5>I3f8mc4g zK(#kN{TwY}_S~J@^9}oXQ)reZoi(UQUt1gh(WfI1Sdu*U{<95pVX2;*bMlXuIlr*- z&P<4wPij`aN9$LyBAT;@9n}{j_d{FR*0Y$}Hbd(yzg@miDp>X0z~U!{ol&(sx=FQO z6Ps3`yd@|Yj-7as7z7etX3Te>xq*U(J58gWpQ2x$YpHP6NjiR?W?~f-+-+g6t(Rrz zuWjBI@Zd33VuMcBT=e%~#m)d_o*u(c3z?6eZ5qJA2diqTh--N$Y}d@ia~t#p`e)7+ zVY*j`b&8ggq5MoZmE;s7)2fNnF|?EcZd$@=9z#*e1p;#6=Wt>|Q~WgG8a8#{9;Rz8 z-kUE7Oin@#HgjQ=Y`Y0uucal?sw=x|JIw?Pyl9!|`>NLv#*gIEG<*UtG`MXQm27_K z^ISvvO(VMhUS~2!bcwqv`d*;OaUG0NtmtYZBgsewy!+=-T?$h`#FUaW1=i^V6f{sO`|DC zWWI)asqwkb*2ah`rc7DTBDH_ua`#$1Z5-Y_Z_E%ouIMncr~dbc!^sQ0R>ey$RtFXvX=Ng6ekpb`qt*X+LzC)XTBR0eDFN^hzo z0I70!c-@T#kX03o?LSXutmSyX$Qj_EW=8W_Rk zoc2dECwAcMC+FjhLnXIDIQJ$ofMNQNr9a1=AgQzIKV55tSVFx`GT^YQ&r6;dI&?03 z+RQs7*&G`4W}~Ctcp}K7cqrs};RP2qKltURUCYb|R>NPHJ+JEkEPrB{Ci^-E+3Az#-S%3865i;X)x_IinfH8`oyHLLSi8Z}@ zxCcQ~_||>guoE?_=8EWO1Yx3+a7NM$+<`i3Z;v4O$EaTQ&z`ITj= zSbxRG-`f}C{6Wi>zykd|lAs_#i??#G%CeZI7bUcl@~bWe7BWX@4+908^p^^Tmq3)8 z9?FG-E#b1GlaA>QW$DZ=UC@weUt_|3!ZHYug5H|J7Om@=?O&n$Ky*+l88oMA$6KQ- zrcd~lk4wbNb;_qU|3C~q1ETRnGe(TJp#keUbBt6j1ln=j7mh}cao0qW#Mn7Z<`Z9p zI34ogZD7F9V+vC9#f%9&8^`l_rzkD3P{vLQnvj7k$No@%O3Y@rskL4BB&%71T_$2? znF;3=LZjuKij`-1n~RBsp5&$!pAP+pAk|%^{90FMr__7G2U2>-Yv+xj_;9iM9rlT{ zMj0;2ifdUQbnj$8+gMY_(CNg~La#SV z70zQAj-m8dTN)ZO2Bw(<$#?_~h(*yC#Nsf+DX-uVF}X;ifCjd2tX!_*K(V|=l4K&i zRQZM!tPMUmP*cS|LMG_t=bm+W({&=D>&l>(B}5l$J3u`{weVCAMI`byNqc1vD~Vc> z_Va3}+sqlZPIO4D8*G=m@XGJjA*JhHl`ljU2}YCzOEdluk4HXRv(jBr;Ori_x?P$> z)K|TBFDMupAw+kH>i|QhDIkcnKcmxK)g(@+2YcIM(;n&<^WW4j z$2jKO)IagvRFU(`Xf?~r(T$;( zalTk|L-v40`F!PofJ2w4LIa+3*F}HZ$utceLrN<6LnOoI3SYDWec;_#=3vW)E z-*|N}dQhfdsSFfrAJBSzNq4>kKLNq*6t#E(Z|u$x@9ZwzS4I;pFbEn@0~a& zUvPy};^}$`#xNcTAS>>y2iFtKOO+dW# zmZBl0k_FSN-9LzJam_1r$y|OzJh19_O-m$M%AUJW6KLeAJ$NK?Ny(v*V4F zXccet@_obno!2(lk@7fCB?>Mb2cf)Zv-Iy)=hkO=I$A#bnh|kcsZ!gm1Rt@_1M8Yc zyI-dKle4&EDTvIeh{KrX6`DczczFOOAOTg1yvB8&5KG`Fkc=&gIaX%6(y;f~;}m*Y zld?u|HYHlfq+CeT9AfQ1bFk4E^bs~=5(^L}BAU5L7G0T_E{F63*Z$_f?yV@7WT~x* z%TgIDoYTQSY|zijeGc+eM~{xTYokDYb<#oLa9K7d`%IK-6f7XxK-i(cp3~b+dTJSx zneSNwst-C8NU`+)Q`&cj!<|KcZ+a1tM34|95hZG(cM^*gL5k=lA$lFX4Uud}2$KXs z)C7@Ww9#u4j83#sMhSz_+ZcnH;k~mPcK2s~&-*;@yMOF6Yro(7J@?#GKj++g5yRG7 zBz}BES!;1y^a{{Q^)ogBzv8X8^PwL%0@++$*(4mcYNvooElUAB`}kII)iAaIzzn*e z^;}vQTyui^;`+NgB~@2%auLMqI1JDJ?HE-kWVU{LiaB>rvp|Oy;l~l$4jqd~I!-C3 zKhT@V_rkyHdm)8+CBnG9uM%7uh&TF>CH83LKtzaTYa5?*A+nY$hC9jHxojrFG_sLsfwCZ^uO6|+!2!lyt*#)Hm)!q;Gf!d0d*W`5d zS*@^C%W9wr4=xx)J5+S2jCxkvnMgr)U>>OShX2qexZ=7X7o-vlv$L~JF+yAO8)ovF z@m^XM2z_w1oZ7htqESW03doJFGS3~xG?gDP9x+Yw)WN!o$Ggr(P44Xv3VO?fs@VtGK4@jx{dCWEcYc7N3-Gzx@Ut>zF*t@-iub zLnPB%^mV&W$_5UnNJ@3YfsA_5ICaxoUA2e9+SC9~vHSj}XQxk&$CM9g;!a~B=r56e zKn&YB(^cIUT@O&ABKiMBMXVU09;p_9Q1ZVBoh^;06gO(*lro!x#ej#-MAYWAqY{lWFdlzl?YYHe-KK${QYcS zj6}XHM)8G$pi#Fz%@OLp3~yenz^vthZIs0hLhyFFUZT=<*gGSaMvx9%U|A@^WhZ7f z_+w@2pF_8cOC9Pn9YK-X&bVPLDj72m?)dXvM~`eVviZ*C0ldnIWvs$)bmL2%>&W)E z<h)M2)J#X2(F#VB7lWl2@{SM$twFp>!hJP>?jO6~OD$%sAae zu&)SPL+NG{+-!9U&C~_JP#xYHKtq${JL5V8d5PChUn8fU<#^Oxqoi^1dj#bPI}l+1 zohz)95bd(8pigbhd4x;|F6`ZU9NW1jVRA`w>-+DdL$CF%?6%%(8h$^)@_r1|$t11Ls2v>Ib1xAm$ND00w-*UqZzN z)IOr?E6WJrS)abC@=p_fGZ)m1=g-x94{`~BP-r?m+r!Fe2$O*c1@(ss(H5W1_GAxJ zZUbNul|cuhE*+f$$+^!9qrs<&-uy7ND$;foguovj_2ike&}K?7EK?d{bWAMl4HrP2 zfSF6HOrh54_d0ao5!MTB@u|boxwV+wZGR$TC~*A7-|MQ&Q}NSdTc8=_oQ|PG z&)AkM&R?H}UgLdZ=p~9w7!kVQ^>g27C9?gpQ01IUSoPwAs+L|;UrxU%C3ag; zAq8ei&VKs7V5o*FqW!wA?r;y-F($w9QtNCpKw{tLwicn?>mq4RN{#|~l9*idgSIXwI6nM=w1)?B+tk)R2;X6XqT%VC|2Mx}E#Ek6v7qd$@b!lq%Y0oL~Tt5R&N1PPb4$f!#f z-fAZ?=xn|YqEwyiv7p%;K7Xz+jw8CUq9+&)q(XQoWx+?FBlUz(00xM z6xgQ6@}PkoRCsVia=|WveJ=Fx_CmUHP+~)Yn*Jp8?oeZ_i#Lq_TY{luDW`o^-McU$ zJ2&?Bx>ZV>4IH%Dz(GyLh|N5;*yUH{aQW6+io2=yiV#)<8ztW~VG%#>6aZolR*SH< zf$M_)Z#euvg|z0XztR`3SRYe&m#gS3GON!y*E6!T6VQQrv9!;9SCP(L|geS{d3ZzM#C zj8>}S>!T3);aO+^L{-@JK(-Z8^;XzTF3x$W&t(*FR|RKrV}r1HE)CPpl4_5G z8qo|i8y|DUVihFxNlMS=d9t-ADa&1Qm31zxR0)>q*jBOZ^mt$19-F5jsN<$(ytgB+0VXVjqXW60Wf@th zO3BqrwyS(Y*PDf^FVB^9wHdz;H%gUeNLtl@oomsf zYr@MMwU{J&HLFkuu~7m5vTZjI)un$AX5N-UGrM}FY^+nt={KAC^iMv9LX^`MCEL8j zkdnI1hNK{8(kD$)?B=>(yb=YZ+E~rqMoVi+k6;(bxLQj8vq}V6rp~t+l%jL8ds(!u z2o(IddC6qirLA=GYT~bo{c0MM=}~~8D*?z*T@b%qL?~S+Y04~|16BT2FNt`U85oQ1 zlKjUKg)>{{L%iL7rBOyd&zH~4)b+Gs+K1+cOU7JGi!~%1st6!RbsY^n!WCT47ElrF zKKr0}=ctGxc!q2Vj0XJwoTX+{a>o3|=w@=8mY>{Ta^wiy6; zKTxB>7;f}puKBX(ug#{0gE_W;zQnDCqqj|$t4Kee(LL{}hj&=PM^{s$QX0m8C{?qC z%YNKQi_Q=d){r2(jecmP`*TX9D!uJGc2JI|pUK!XcWRJ{cI`TW5<$*ZefHa4kd>m3 ztL{CgyL~9?kbJ%(s9{ae+&fO z&04dBgIdi};Dpgk?R{MtB<(oB+W^oG(u$!w!xG$_B1GUIZ|+d&S@VIHmr; zV}g#~#HDe17!wD{6O$eelO1Cj#bT4#hka(@OK*??C)qvc^1wa zOg)S)Q|!HtrlTl*-L_9&Ji9jRyBH+1U@bm`WH&m%03B`Lz2U0<{O{l985${A?aCq zK{fS4oC;}UgBV)ufG&%*29EGgZ_5VXmpiuRASie{x?~Z8^j8n zKjS;2{KiRegEI{PyFoJ@Z;tpvogd9Q!V{rQw8NR>UI z(|XIaP(ZdVNfI_Dur)XYCdFywU$@9Yo8J}GZoe_@JYbU^H2oE^+`V@hh;cPWO#M3d#`c2wNJRI@QdOVmH|%lHRT=vtrKHp zRwzyz?1=!FdeeOIjFT*5$<+7BXbzOVXQf%SF%l1;4YIw60YT}Fjp?E*C$6j-I{#Pa zoe$`|!-34xNiM0WY}v`Q5MwpAZb!RJW4r9p*_ggc`d(||3*{vJB#!W{IV>M9SeA6W z0u9Xd`nFAg;f>y+C_USDVWZATzjjxwx%->v!mdieGDl7VWwny%j)l%2Da0Djh7${C zLr<*F8Ik+-eNXZV!=)G48#YyDt*{L)Jd+?T|3p5@s=2D{dcVX#A?@N?q*0Yn+*ZhcvBm7&K&lfC7bgB9KL1z0uYfjqjjS zST{&}%Ylq~V4+z5J=V%bJP=v8d;s?>f}24j*+LI1Ig@Q~fTY7#Lldlx!KL*z4AI-$ zYYPX5v!_jdTT+l{3~TtEOG*23#D~t>JK6@dXW^GD2i`Z?MSWCOO(@VJE@^WYu$l4P zx~s%+>GvL^o zi^-4UTOHp1Rt`3OTotUawv9Uxwiqlfvb9~;&g!jgfqv&rx-Hean= z?TP8+n}f|<<#Ajx);i58aHi1lXI*juWiqr2O|Obe)!zEgyZ6?eRwdaZ#Pwwi zIxTQhLs(pEuVY>$iR1em6g5vYy$aBT>XkQ(P;Jy+BN1V-QzbQc(EgeRr=HmWY~BAkHNr z2~`2McF}yopbyM#eKfdEs+y!6(YrobP18vHKJOV5S;cMhYNn8fdF$ug*An9rV9+yGFKS`E!&r zs~9;E1`UKkH7E)~|2$k_z>sJI=b9~nH=dQRga=>;?BIXR1iYn|W^R+V1+^W_TpK6~ zN3RhVknaRou`A`>C5dbPu)k@}mS?<%?h*M>Hy_~EEMSg&CDUaxhiLZ~IQ)onUu?9A zaN(oqnhzRVs`v8ChvTKRkZisSJs*a2+hhpwpc6nzMs^Ij525zqF?Y*|iMR5mdvh3{ z+9qT;$3KDGG>7cXn2<#Lct3a1ca;gIZ=u(?e~{g=?5g;nbqu8hs(8xq~uE0Xvn z4tXZR$hIO^X%AK{f=jI1RVlMK&eVGAK}#dcTSKP}b9<08^=}`Gja1v;khos2V3t$4 z7Ji6^Q8ee%$M%RJwTS8#jVvPG)I2KhO6VzF6NvX*esfz#rfv>bZv?AGfXiC{G9syv z%rG8CGjZSa-Wthtn|J7th*X>G+o};UbCnA&4Ugsz|7qEz-RY{jm7rdgfw41{D^zw?mJJ`YF#jW*+=TITB_0c?#^MFH684Wc-MA^^;?1W4O)EW^6G2pG|B!R!TGm8StWXNos?S5jgccR#-?U?IXd~jy)o(9 z?s<6nWwk=$g7$VT{q_rZk>iG4rsj@r2!#dGts3@N%dLwAjdi@z(;*{iCrnib z+VEuMPhq#bfL0tsXjeh!8a9L62eXqDW=o6}j==HBmAxPRP{xL~B1xNm9*$&$>xbUt zl`lCcc^Phk_m=>e!RAm?s{1&?1P=8FOgdBUx3KXFs+tJL6lHaT`Oi{R0$$C?FeB$V zDVP@?_r`j=ln<4#qR)|4*!$t}VVRXhBRi3yHx%8T6j4P%>PfSPDe9}+%EGwbHzO9a zB@XqMEcgw5+VGxN@9V1i!(5>OZ){R7BpzycSHrJ;hh2Qc57Fm57qt9Ui4KWtmmA@f zW0&=Ez74wOgMJJ9woKqo{G)nP^jO2{di2+&(He|7+MeDO+vc#&yP{h5%EGTkGp&*A z0)VJ`&?DMoLFP#h>NPSC>+>hhn>W7Yg!B%HtPD4b^=|qQOyu4&<#wc~Ah)*`)gjx@ z=xPl^hk3a(1ZUNuIh%c3^ys=L`^YR$Uo3_{r(v4xiT0ae5w{=evq?`F1IMgnHeKvD zdgNNU=<1MD=+bD`@#is&`CtiT8N{Swq`TkA*PVu&;TAcutALF+IqimX9 zvW}(msEX}Y0k`e4jH;XMcUKDSBe>>I+0h#F4T%+|1f0VZex8>}{;^o<3N@VszrA^D zdyEv&UaY)-`q$4ixQD+TsAP0+DdBSovt8#e(5PV_xl-QT7B7aF+4}i~?>0@pW%i=b z@%~`59?pkFdigUBYGAahhPEk{1%4)~=*)8FY{Pni5q1xqDOiFuxgG zf9ZsG6TZr+v}o`?5QBcfB;TqDW>+nIf2l<|pfx)QQNdU!s}S3Lvl?3jWI&)A`!{Kz zj3}+DMpwxg<(Mdqe(OHi77`^M0O+`dZhmJ-e+``*Rv*^Bkh$wOMO?4_}RGT!x zWcoE;o;*#3OLxoxlkDuj(?(O9YA4On7=g-CMrssY{GkWNHK(no#)eZoPXQk;CudOO z;J*C{rvUSh@#|9^_jM?E)u=o25)}?UQ5*#qLG`QFcL7PE{AZ|+dq)_skTV5E zRg?;O0rPdNhD)fu%EJWSoDinpyg`lmJ;wp_2fm11qsDyR-vLF>Z$~;(6VW@Rfcbo$ z3#;+em_GxUWXOuMK1*$?k$Heg!ig7*IjC?cnF}xpRV&JJg_@ihUj~S=VHu2H-n=ZlVHWm{iuR_3%?6heNw>_gPRz8j`$$N4d(!x}IZm7)N?w;jD zQ`B+_`?fA6^VH~!hK+dg@$#Rqafq^gXgS@3TPCVNq zOvzIeOQ>l?I&9hMSD+-*!c|_M=+)Vn={EJ9W{dlz{Ce^_z{kv51D|!x>YC!weNcjy z1+I#`hPF|hd9v?7zRmq~lMCE_8+`q^$43V0+2hX7=2ZK?e|HciXjF2|XLagyShGeo zuC32^5sH{#W-sWDHV@x0i}yxO_kSHqQ;$Y|R1P&;`uVNDAU)AXx`;HTqw&-d=Wa77 zM9qGzoIZ6e4rbyW+3H!DpH_H(wxkE*bneg|X;!oWQuhv7`WaraL_%D*{*hb)_MVQt zXlL!8F0xeE(tCC8y6j|&w=k{r5ZHMRF#Lcd6k;h%V$7T!Ee>Q;N^Zohi>{uMs?E&K zavXcXDrx-(vvr&i={u7=yVdr1mgvXA-+F5Kb;t~Zo?TiIKQ`Ji5f z1dS~5mNMh7YbY9Le=pUv+JCvJSt!k>c-Y7Ag7eQC{j(cokNI5olC5}A3()PoU+2cM zUoT%=4n_T*#(h7vSN(2LEOP2#oy{Cv*(p^f*B_`Irc+&LR+}TvsY<`h-2R?*JIG4T zi&)mi2ax1|f>wfqMAFSd1r*9|LG7~hR7i{4Rj)sB&vVXm!EgcO%yS@qD_KUy8Jq|j z0=vA;H(86*y{mCUi*@b(Yq(WOTuNG%kp~GrH*Oo}t$^?Dq(BmUn*RSbh>2=r+ zw>(Fh;@wHhrs1JNrB__Pv|`l-6PbyN)u!*Q=7L!2L4jfLN`N4Ax*uV6MMk8%r>Bm! zE4^NxNXp#uGk)*Za%>N2zXi$y+9r?bjn?6eb6PAY0{DDHv}~`@zvB!R@({Ox!*dpg zg1fRS(ZQW6LgjN3kcLZ18qZWkD)mZTELsv|Bs>PZdnq=bON!_R&J3via~Xq)(lvQ` z-CUySQ?dIs;&Q!N?Tb+x=lR1Ns>OOvm=!hlYCIgQc^%hu*C&lo@|r>}ZpvEHtEyfm2R2U^sXEG}kkG<>%=5rHc>6=_+`6-Y@!C2->BiAeldr*AMQ| zxxlADG)7ZinKNF=9fC-Z@|85zVb7pXJ(+-#7xi}2L zwFbO;ra+Fh-$I@AcR>Zzcc=zjs`LYiI0HCaMZ&PfXF;mE<#0Y%c{LY|PJaEOKM|cd z)+fZ;YZlwKHW86TdQ4iF8Br;A95QQ*=QeMKIYxm4Vx#SqPg0r5SxPv%?-XB22+$ds zat~Y9;-^lG#@u_4-~P5p?#L? zn9L*SV0L0e0L5{Ay4|1Ih-cu_sRel!Ym(#97HHWAp)mG@`Q~-lE11HN2#1f`V&dVk z@T6StE6^rcW3dzWeqcszk{ zyGC0%(o)TsLcsbnFVOsrp-0c3!eQG@)8=CXh<2~o$}CgW20qQ5VKB zovD|(sf29DF9Lh^+Y1IGW%=2t1bvypaKmpBFSGYDrVcSRB`(a}`~hHa};jXR~{4RS97KWaV*L|$yPAg`E4wp zN|rE{3s&gl{T_QH3dJW=Mv*^DC)NXdsP^5D$}E=8cA<}ADZl{|e)QvuT=IbXn@ zR^Kj1ynW)l@D(<%pWpf{dK(2!LhjXM=SqklM4^CbI>PREiXFX%xo*`K&xrH50$IZ# zfMGa29bWS6FkLI-^dF{&>DVs76vRa~@w3tIh@Fu7`m|`1ckmR%5qPOv5!(ip;Jtv7 zRmolIH8jkbYXBjV5rK}1(OUyXugFOoCFQt*VK)P2H>}q*Ko0q8=yP3FBG~1>T*Ds! z-Xu;EQXYfv%K|}~6~EDvPnq4Ejlw1b1bEdR$qq)}K+^_8*J8}O!_9(*Mf{+G5==R- zpT(+?jXSiocyOhD6-ccDlZa(YgNdEr(h>kUxZ>4eM5ck%UBC(ww7o4?G?4E|g|=k4Rq91@XEh zm3aLQ5AZY3-)6P=xESZyJ5d~x#W0=E-t6Uv7>IJqk(CeOYx8Js6K`*mvioyQQGA$| zZFxTbtx(fi8K-J`3er+ds%tj~$J=P3%Ykt;hsmX<)?>kuSj%8v({NVal#tD3cs}!GZ7|m}%B(-%|72!fn54 zMGxN?@jsBS?*eIL^w{H>0Qm6)EDy~K{SOZ&@=f|tlx*dAJ#E8*3Ic5+41Zn)3Dd-+_xDyNoR2saDT5A0|6Bsz7iHuAmt6n~|0mX7#YIAS7!fuqB~kp>BqG_#!;~cfI#rgtAgvlHQUS=#Bbhnwr1FiW^}&- zoOvHR6F|4BMO$9!m{&J>{nru%m`*)SahXwPI$;{ISHi_E0|8gzDtl7HpI+Dn9^3cw zf%2Z{dIzV97_E}to|x)N*BW$bhC_bdIO6$5Q3{`?eo0hw?@K*^Ihj5_p~59D1~LNa zRc8+wrct4QqGDX1N~bRZ>;ir2TDcE62q!epdGz}yCUrj@P3YC-A7ZL|M^^%)+gaz+ zh<5)P{e`X=mC<>?=!dz7Uhh@eqo;V_fNPyTc42$8fbzLkuw>#)ki8QFf)Kc$g3Vp~ zJVPa3JazGh8Zep(2O|=wj+?*$;5Ys2)ZFE%i+A)W_7<QjlrMK115=y z^c=oLZK}G!C(rYG&+pX|Fuk9qU^YX?s8M^0Cm)b|`9NkOm5J`#KM#Umha(LZ)EIbl z9H3R=rZ*<2N!i8M6jYi-iloMcCyM2OAT;OOK&l^A2C`zPZ4$LNP6czhc=KykITa3o zk&G$mnH{;1n(!*pf||NHkBp=APf3hur?}@=*2s<6j^x07)FiSz*~%f*hWP9 zA~o)6{|T5762p~ng_<1=0lKEMJkHEaP3$Kq7D9INh7eU*Fb1+vC__-8I@Omz#@-z@ z5TQoVDQjggRlaL)sV>^Dz(<=(yb5S^p$8CIT--gXoTHh~{0N!0PoTU5Wg z^lPi(>&o}!>Y@}J^|hpd8^O79F{v{2*aITTW|uVzxZ8k zyu?Uov4UP=bmo)(iuJS9g_=kaYKgad!BAj2n;7*Fy0G;=12QH>iC8!JY_oQ1UnPxn zvK6C1xGGZxOMv(Myw}g3;;}G7w0nj>Fr{N}W2ve~0*I>7-XfgT1cN6a@Ns>gajzkV z2ebIaBIbeWR964>!s4+u@6fTe=;7O*sSY`ieTeyErPM@c2a5SDNsXTtoVn&oPv(X> z4vQUZ%GPHjm$?qJKx%L_W7$E!4WQWeTN*~Zp+4;2DMP9)HJYdH1wK=uecAqs?M%v% z)rC&~0@S)lC}Vw8dj*?rI}{qlzu!{#(i8-wO>(XFe}AZ-MzX&bxiI+)?nHf<`U-_S#tCfWtxht z@GuF+dT*_k(DF%viaF0Z-OklSaz{`GB$%65`_V(O0^i{!Fs_OT{8w`~oasMe5W(0Q9 z_yshNLVh-UOLHnYC~!PRU|&8LHO72)pqM1NZI!*SonrFfAr<^0tHOY-*DIF3^uhA? zuG;|dyPbZKnwTAw<^rbAT+@ZBG4=`6$`ZNQHfZ^5Y6Rgs%_8(Dbibwg3;0!PcHQ!a_)%jzaK;w!tNyZ(H7a&5eR~T&yjxAMmg0$DOiR(hfwA2oxiy2Pkz~P2#&bN zOMlJqsz_QAwQ;nwDAXiKv+jjtE&^&&HVNU)o&(n?0w`ua)1nCSD;0H}BzD5>lx1rS4g!YcW$pR(E*w*M|-*BT4C)C@c05EL##|=+=f`nELsD@a(`(z{tk= zNbc4kc@h;vXG?}IGge3F*Fz-}iJPuB`oc6V?MFNl&pit5#dAxXy-AF?kRB#+OZAE*Y+#<_ST1$JDef=dN@X8p$tqN zYK~oh*!1X)Y&6=uF+v1G=C-PflQ0=D>jmjw!{s+S?_TVG^Akhd!t$QVsgx8++?u6b zUKu=snSU;n)=Q){5<;;SYP{+wo;C2zP9O^+{vwW!s@&{NVBOaAM`#IZ->-V(|rylwTUzr26v}T;hdX z5FP&?!Kls6ev9)w=+2)ea=6q#_FJc4L~VzF$*lbt^Zk>seZH*2 z{bt7Rx1XnY*mqVv>F2tjxoeNl1gafLVo2ed>o|xh`m%jmNUkUEXBQsnFY6(*@||k} z+g2fEX@u|LvUx5mHM)+$G;qMI?@b644>P96ugr7V{4>;1x932?v+wkB?Xa}$P!>jq zKw@Uy`NOIGy$ktj_7#?k6`2{ez3{X7)s8_9)V|ms_47&2FzWXd z4w+{0t3RAOJ2er#@r8zWJ!0oka6>m{a6127uZolJdIvGd`n9F=FT6gcI~lWrdqSE?N|HG4P@LUb^$!B(+ z!XLdI=9(hUG2y#c`OYn}EKT*^KG`gDIf3fqIWQKj9f%EGhyJ2I) z@!Q1i61^NJ=$dTOvr(p@D6M5dfw;MAadV<^vcObMYg_L$Xx#DNpCoH*dx8bwP-I>@ zCm45i&@Z~*TWf^9*u?Geq|3L&Y5=U7ftrN+EOGFSY8`1K2MI48j87c#9f3jyA>3!P z1Q`CY_6?K-K+v{n*LxIjJ!CBwJ3qZ^JR3_qzu}IDz8_ySHpIQCgt85?9AW@QzcWq z$B_`3S}Vwj|7l%zPj)eO|NA%*W)@Ekj#; zH@8agA2|@%4Ys*F;sRvAMxjN=brDLifJHomX~oCLDPRY2W8j#~rbL;4S^HZQYu}Ax z+_6NP6w|bQfn}$)?jlp567octlf&8vqmUwfm9O@3vVyzNh<3);Kh;$0-$iJ(cx)Fk zSs^vBtnqS-E#FM)O&FAVlU@s~G!!HuTh3#x+HrD{i-WG9Jfnd6U`x43q|bANEEB2A z^~;a|6@N}a0IcugcerkA2o}hpy}DzENe1*KUcuM%dVXxdjoYM*zuLFC9p5D;67luP zH;yric}xv0ofAg;dG@D%w`&^^U?k%>Jo?j?YQ<`IrADWLjK0%cy?HT*kiCodLQ9-E@`0(t z+js4aR`O71Kk}@pdS;3FX2Nn|4z#jvMpJ&eR&w=+Qm0)sd2&S(wD=drXkFgXlivc( zD2dyr?6MunD!dA;kT`be^UfdO$NX%`fy*Ok`yT@n6`hA#ChO97p<+cTnk(h>a{-;~ zDF6-U8k%d+L2cYYZ$^00ZuQfxC&O*vBvbT(*s*0s9%egAVA z(pY!Ds|(x&EZ{*LU!b%+wOITsmAXa2hp)6S??hwWk$<^p(JYLAleDA8kn-VQEs@$0 zyU||crDOj^IGU6nrod0WfGrY}GTpEgG3yGbykwF{d{|Mrl(yr{ayJ^7Hok>-B!uHMVe- zgt}$0sYzBn0_T*p0+;;xgxpmy>r`^df9ctS1A%d8op0{MQ;Nx7for5Wks-aKK7m=l z#PdJjX-iUUJ-#~IFUK}$)zb<;e(3VUqC0PAc9!83k6qy7;HNKrlbDBQ$~-fY_;mSU zL`TWOWSM4WL?0!V{A|HNCfVCrdm5G!_Gtrt3G76CV0EbN|WO$?oTGt^j~(M zhte55VAZ$2WAlxLUUx|^Bw&#rdy{52+h+IeNWY@VZj4efW1X4%q`%0`Xg8zzd@Jzr zH}_|d2=jzDhV}35U@wQEhtn`dq=lzm=38CMO$fR=SC|$(k6{$T>CWz2ZGZ%&RaHxW zm$Pk03rfP#0%prHvF?Mxn9_dQ*^>PhGBv!ra*=hYDzD!M(ic-0f>PawP)>QUn>Fl& zE`P0DhB^ot+*{MB6rE1-a8uk518_en*2ZV9{X2Ty;lp1fCYMoCj9x!x+8r+Z^NB#0 zGbKLQF!Qd!OeVNG=o?hYG~1cRf1cKk2F`Hiy;jPuNc_twXznjwtg<;V+m-qMLx`7i z(y@*lE5b*2HP8}a{a`m2_&5F(I76ub7~^b2ciI2Xk0!N;sI53vSec{pEI1_QV@F2J zKU4kJbb2V!PT6Fa^?&&Ex2i-&e4RT`{9^2Rvx;`WSNoB`(?ex-GEPGFF}+a}qQ#p# za{bTr0ePSsm{qmauU7o@q4>i zzW-pDR7Ey?6*NC&r3g^b)9Q@Xtj+HP94f_isZa<4l;j72Y;rT^g~MdCjS!2x^&w^W zWb?#R_d)8N)OU>cBi2S_eYzQ(VEq{*kBX|S7C)Oh;PvwS^uaMChP0llp^(BJ_ug|< zLd_TU>I!;BKL+3!#>o2?|8ReoRNi|(zd^ZvuTC5a1ubuM zF&g8-<@8r>Ak?D;9s6w7o&{+KB9zNBwW%$zV$@N$P&PRq6)1F3RIlhQi(Xei~gpBw!wqgP$=mWgjXRv+qa4 z4b|Om?HZAPEi@2}S6`c=^86-N^s3x!_{k3;T!o+8QIOE4OcyF@X2Iu-Qtf@IbzDV8>2^K?Qdt8uM7%Nv3kyHo zWcmK|wYWD+)YOd%r~dlD=ZArylOjgg#X7!dBlqL0k11e6$k5Z0=zhii+olIwsB7f^ z$|_#HWW!>|ytXG*kos)%P~nia!!6UQ$X4x}youxo2up7cNU%nduva-?O8 aU9)a&zS*t)+;89?HD%4;vu{3p`hNhEH*~Q8 literal 0 HcmV?d00001 diff --git a/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/icon.svg b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/icon.svg new file mode 100644 index 000000000000..bd4f2c30f503 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/icon.svg @@ -0,0 +1,18 @@ + + + + Icon-Architecture/64/Arch_Amazon-DynamoDB_64 + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/integration.json b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/integration.json new file mode 100644 index 000000000000..8453c6b5ef8a --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/integration.json @@ -0,0 +1,394 @@ +{ + "id": "dynamodb", + "title": "DynamoDB", + "icon": "file://icon.svg", + "overview": "file://overview.md", + "supported_signals": { + "metrics": true, + "logs": false + }, + "data_collected": { + "metrics": [ + { + "name": "aws_DynamoDB_AccountMaxReads_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxReads_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxReads_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxReads_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelReads_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelReads_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelReads_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelReads_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelWrites_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelWrites_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelWrites_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxTableLevelWrites_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxWrites_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxWrites_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxWrites_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountMaxWrites_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_count", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_max", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_min", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedReadCapacityUtilization_sum", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_count", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_max", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_min", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_AccountProvisionedWriteCapacityUtilization_sum", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedReadCapacityUnits_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedReadCapacityUnits_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedReadCapacityUnits_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedReadCapacityUnits_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedWriteCapacityUnits_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedWriteCapacityUnits_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedWriteCapacityUnits_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ConsumedWriteCapacityUnits_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_count", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_max", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_min", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableReadCapacityUtilization_sum", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_count", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_max", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_min", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_MaxProvisionedTableWriteCapacityUtilization_sum", + "unit": "Percent", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ReturnedItemCount_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ReturnedItemCount_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ReturnedItemCount_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ReturnedItemCount_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_SuccessfulRequestLatency_count", + "unit": "Milliseconds", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_SuccessfulRequestLatency_max", + "unit": "Milliseconds", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_SuccessfulRequestLatency_min", + "unit": "Milliseconds", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_SuccessfulRequestLatency_sum", + "unit": "Milliseconds", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ThrottledRequests_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ThrottledRequests_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ThrottledRequests_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_ThrottledRequests_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_UserErrors_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_UserErrors_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_UserErrors_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_UserErrors_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_WriteThrottleEvents_count", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_WriteThrottleEvents_max", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_WriteThrottleEvents_min", + "unit": "Count", + "type": "Gauge", + "description": "" + }, + { + "name": "aws_DynamoDB_WriteThrottleEvents_sum", + "unit": "Count", + "type": "Gauge", + "description": "" + } + ] + }, + "telemetry_collection_strategy": { + "aws_metrics": { + "cloudwatch_metric_stream_filters": [ + { + "Namespace": "AWS/DynamoDB" + } + ] + } + }, + "assets": { + "dashboards": [ + { + "id": "overview", + "title": "DynamoDB Overview", + "description": "Overview of DynamoDB", + "image": "file://assets/dashboards/overview.png", + "definition": "file://assets/dashboards/overview.json" + } + ] + } +} \ No newline at end of file diff --git a/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/overview.md b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/overview.md new file mode 100644 index 000000000000..3de918d29a48 --- /dev/null +++ b/pkg/query-service/app/cloudintegrations/services/definitions/aws/dynamodb/overview.md @@ -0,0 +1,3 @@ +### Monitor DynamoDB with SigNoz + +Collect DynamoDB Key Metrics and view them with an out of the box dashboard. From d1d7da6c9bf82d5d2802f867a251ec8466b93a27 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Tue, 27 May 2025 13:29:31 +0530 Subject: [PATCH 14/24] chore(preference): add sidenav pinned preference (#8062) --- pkg/types/preferencetypes/preference.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/types/preferencetypes/preference.go b/pkg/types/preferencetypes/preference.go index 9e38064a6d30..16d736567574 100644 --- a/pkg/types/preferencetypes/preference.go +++ b/pkg/types/preferencetypes/preference.go @@ -128,6 +128,16 @@ func NewDefaultPreferenceMap() map[string]Preference { IsDiscreteValues: true, AllowedScopes: []string{"user"}, }, + "SIDENAV_PINNED": { + Key: "SIDENAV_PINNED", + Name: "Keep the primary sidenav always open", + Description: "Controls whether the primary sidenav remains expanded or can be collapsed. When enabled, the sidenav will stay open and pinned to provide constant visibility of navigation options.", + ValueType: "boolean", + DefaultValue: false, + AllowedValues: []interface{}{true, false}, + IsDiscreteValues: true, + AllowedScopes: []string{"user"}, + }, } } From aaeffae1bd53a2f5f8b805dd7aef993b5cef10f2 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Tue, 27 May 2025 13:50:40 +0530 Subject: [PATCH 15/24] feat: added enhancements to legends in panel (#8035) * feat: added enhancements to legends in panel * feat: added option for right side legends * feat: created the legend marker as checkboxes * feat: removed histogram and pie from enhanced legends * feat: row num adjustment * feat: added graph visibilty in panel edit mode also * feat: allignment and fixes * feat: added test cases --- .../WidgetGraph/WidgetGraphs.tsx | 30 + .../RightContainer/RightContainer.styles.scss | 8 + .../NewWidget/RightContainer/constants.ts | 14 + .../NewWidget/RightContainer/index.tsx | 36 +- frontend/src/container/NewWidget/index.tsx | 16 +- .../PanelWrapper/UplotPanelWrapper.tsx | 3 + .../__tests__/enhancedLegend.test.ts | 521 ++++++++++++++++++ .../container/PanelWrapper/enhancedLegend.ts | 246 +++++++++ .../src/lib/uPlotLib/getUplotChartOptions.ts | 288 +++++++++- .../utils/tests/getUplotChartOptions.test.ts | 35 +- frontend/src/styles.scss | 426 +++++++++++++- frontend/src/types/api/dashboard/getAll.ts | 6 + 12 files changed, 1599 insertions(+), 30 deletions(-) create mode 100644 frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts create mode 100644 frontend/src/container/PanelWrapper/enhancedLegend.ts diff --git a/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx b/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx index ab4e67120f46..f142bd704337 100644 --- a/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/WidgetGraph/WidgetGraphs.tsx @@ -1,4 +1,5 @@ import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; +import { ToggleGraphProps } from 'components/Graph/types'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils'; @@ -19,6 +20,7 @@ import { useCallback, useEffect, useRef, + useState, } from 'react'; import { UseQueryResult } from 'react-query'; import { useDispatch } from 'react-redux'; @@ -36,11 +38,37 @@ function WidgetGraph({ selectedGraph, }: WidgetGraphProps): JSX.Element { const graphRef = useRef(null); + const lineChartRef = useRef(); const dispatch = useDispatch(); const urlQuery = useUrlQuery(); const location = useLocation(); const { safeNavigate } = useSafeNavigate(); + // Add legend state management similar to dashboard components + const [graphVisibility, setGraphVisibility] = useState( + Array((queryResponse.data?.payload?.data?.result?.length || 0) + 1).fill( + true, + ), + ); + + // Initialize graph visibility when data changes + useEffect(() => { + if (queryResponse.data?.payload?.data?.result) { + setGraphVisibility( + Array(queryResponse.data.payload.data.result.length + 1).fill(true), + ); + } + }, [queryResponse.data?.payload?.data?.result]); + + // Apply graph visibility when lineChartRef is available + useEffect(() => { + if (!lineChartRef.current) return; + + graphVisibility.forEach((state, index) => { + lineChartRef.current?.toggleGraph(index, state); + }); + }, [graphVisibility]); + const handleBackNavigation = (): void => { const searchParams = new URLSearchParams(window.location.search); const startTime = searchParams.get(QueryParams.startTime); @@ -154,6 +182,8 @@ function WidgetGraph({ onDragSelect={onDragSelect} selectedGraph={selectedGraph} onClickHandler={graphClickHandler} + graphVisibility={graphVisibility} + setGraphVisibility={setGraphVisibility} />
); diff --git a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss index 8855eae49845..a32fcb885033 100644 --- a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss +++ b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss @@ -166,6 +166,14 @@ gap: 8px; } + .legend-position { + margin-top: 16px; + display: flex; + justify-content: space-between; + flex-direction: column; + gap: 8px; + } + .panel-time-text { margin-top: 16px; color: var(--bg-vanilla-400); diff --git a/frontend/src/container/NewWidget/RightContainer/constants.ts b/frontend/src/container/NewWidget/RightContainer/constants.ts index cec2f8a6008f..3735b684a59a 100644 --- a/frontend/src/container/NewWidget/RightContainer/constants.ts +++ b/frontend/src/container/NewWidget/RightContainer/constants.ts @@ -150,3 +150,17 @@ export const panelTypeVsStackingChartPreferences: { [PANEL_TYPES.HISTOGRAM]: false, [PANEL_TYPES.EMPTY_WIDGET]: false, } as const; + +export const panelTypeVsLegendPosition: { + [key in PANEL_TYPES]: boolean; +} = { + [PANEL_TYPES.TIME_SERIES]: true, + [PANEL_TYPES.VALUE]: false, + [PANEL_TYPES.TABLE]: false, + [PANEL_TYPES.LIST]: false, + [PANEL_TYPES.PIE]: false, + [PANEL_TYPES.BAR]: true, + [PANEL_TYPES.TRACE]: false, + [PANEL_TYPES.HISTOGRAM]: false, + [PANEL_TYPES.EMPTY_WIDGET]: false, +} as const; diff --git a/frontend/src/container/NewWidget/RightContainer/index.tsx b/frontend/src/container/NewWidget/RightContainer/index.tsx index 86ada0d4bac9..ac7f0fede5fb 100644 --- a/frontend/src/container/NewWidget/RightContainer/index.tsx +++ b/frontend/src/container/NewWidget/RightContainer/index.tsx @@ -30,7 +30,11 @@ import { useRef, useState, } from 'react'; -import { ColumnUnit, Widgets } from 'types/api/dashboard/getAll'; +import { + ColumnUnit, + LegendPosition, + Widgets, +} from 'types/api/dashboard/getAll'; import { DataSource } from 'types/common/queryBuilder'; import { popupContainer } from 'utils/selectPopupContainer'; @@ -40,6 +44,7 @@ import { panelTypeVsColumnUnitPreferences, panelTypeVsCreateAlert, panelTypeVsFillSpan, + panelTypeVsLegendPosition, panelTypeVsLogScale, panelTypeVsPanelTimePreferences, panelTypeVsSoftMinMax, @@ -98,6 +103,8 @@ function RightContainer({ setColumnUnits, isLogScale, setIsLogScale, + legendPosition, + setLegendPosition, }: RightContainerProps): JSX.Element { const { selectedDashboard } = useDashboard(); const [inputValue, setInputValue] = useState(title); @@ -128,6 +135,7 @@ function RightContainer({ panelTypeVsStackingChartPreferences[selectedGraph]; const allowPanelTimePreference = panelTypeVsPanelTimePreferences[selectedGraph]; + const allowLegendPosition = panelTypeVsLegendPosition[selectedGraph]; const allowPanelColumnPreference = panelTypeVsColumnUnitPreferences[selectedGraph]; @@ -430,6 +438,30 @@ function RightContainer({ )} + + {allowLegendPosition && ( +
+ Legend Position + +
+ )} {allowCreateAlerts && ( @@ -495,6 +527,8 @@ interface RightContainerProps { setSoftMax: Dispatch>; isLogScale: boolean; setIsLogScale: Dispatch>; + legendPosition: LegendPosition; + setLegendPosition: Dispatch>; } RightContainer.defaultProps = { diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index fc337081d03f..06d73f575118 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -37,7 +37,12 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { generatePath, useParams } from 'react-router-dom'; import { AppState } from 'store/reducers'; -import { ColumnUnit, Dashboard, Widgets } from 'types/api/dashboard/getAll'; +import { + ColumnUnit, + Dashboard, + LegendPosition, + Widgets, +} from 'types/api/dashboard/getAll'; import { IField } from 'types/api/logs/fields'; import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; @@ -183,6 +188,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { const [isLogScale, setIsLogScale] = useState( selectedWidget?.isLogScale || false, ); + const [legendPosition, setLegendPosition] = useState( + selectedWidget?.legendPosition || LegendPosition.BOTTOM, + ); const [saveModal, setSaveModal] = useState(false); const [discardModal, setDiscardModal] = useState(false); @@ -248,6 +256,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { selectedLogFields, selectedTracesFields, isLogScale, + legendPosition, columnWidths: columnWidths?.[selectedWidget?.id], }; }); @@ -272,6 +281,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { combineHistogram, stackedBarChart, isLogScale, + legendPosition, columnWidths, ]); @@ -471,6 +481,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false, selectedLogFields: selectedWidget?.selectedLogFields || [], selectedTracesFields: selectedWidget?.selectedTracesFields || [], + legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM, }, ] : [ @@ -498,6 +509,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { mergeAllActiveQueries: selectedWidget?.mergeAllActiveQueries || false, selectedLogFields: selectedWidget?.selectedLogFields || [], selectedTracesFields: selectedWidget?.selectedTracesFields || [], + legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM, }, ...afterWidgets, ], @@ -752,6 +764,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { setIsFillSpans={setIsFillSpans} isLogScale={isLogScale} setIsLogScale={setIsLogScale} + legendPosition={legendPosition} + setLegendPosition={setLegendPosition} softMin={softMin} setSoftMin={setSoftMin} softMax={softMax} diff --git a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx index 77b29c779854..67099b48bd60 100644 --- a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx +++ b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx @@ -138,6 +138,8 @@ function UplotPanelWrapper({ timezone: timezone.value, customSeries, isLogScale: widget?.isLogScale, + enhancedLegend: true, // Enable enhanced legend + legendPosition: widget?.legendPosition, }), [ widget?.id, @@ -163,6 +165,7 @@ function UplotPanelWrapper({ timezone.value, customSeries, widget?.isLogScale, + widget?.legendPosition, ], ); diff --git a/frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts b/frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts new file mode 100644 index 000000000000..036a5403973f --- /dev/null +++ b/frontend/src/container/PanelWrapper/__tests__/enhancedLegend.test.ts @@ -0,0 +1,521 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { Dimensions } from 'hooks/useDimensions'; +import { LegendPosition } from 'types/api/dashboard/getAll'; + +import { + applyEnhancedLegendStyling, + calculateEnhancedLegendConfig, + EnhancedLegendConfig, +} from '../enhancedLegend'; + +describe('Enhanced Legend Functionality', () => { + const mockDimensions: Dimensions = { + width: 800, + height: 400, + }; + + const mockConfig: EnhancedLegendConfig = { + minHeight: 46, + maxHeight: 80, + calculatedHeight: 60, + showScrollbar: false, + requiredRows: 2, + minWidth: 150, + maxWidth: 300, + calculatedWidth: 200, + }; + + describe('calculateEnhancedLegendConfig', () => { + describe('Bottom Legend Configuration', () => { + it('should calculate correct configuration for bottom legend with few series', () => { + const config = calculateEnhancedLegendConfig( + mockDimensions, + 3, + ['Series A', 'Series B', 'Series C'], + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.minHeight).toBe(46); // lineHeight (34) + padding (12) + expect(config.showScrollbar).toBe(false); + expect(config.requiredRows).toBeGreaterThanOrEqual(1); // Actual behavior may vary + }); + + it('should calculate correct configuration for bottom legend with many series', () => { + const longSeriesLabels = Array.from( + { length: 10 }, + (_, i) => `Very Long Series Name ${i + 1}`, + ); + + const config = calculateEnhancedLegendConfig( + mockDimensions, + 10, + longSeriesLabels, + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.showScrollbar).toBe(true); + expect(config.requiredRows).toBeGreaterThan(2); + expect(config.maxHeight).toBeLessThanOrEqual(80); // absoluteMaxHeight constraint + }); + + it('should handle responsive width adjustments for bottom legend', () => { + const narrowDimensions: Dimensions = { width: 300, height: 400 }; + const wideDimensions: Dimensions = { width: 1200, height: 400 }; + + const narrowConfig = calculateEnhancedLegendConfig( + narrowDimensions, + 5, + ['Series A', 'Series B', 'Series C', 'Series D', 'Series E'], + LegendPosition.BOTTOM, + ); + + const wideConfig = calculateEnhancedLegendConfig( + wideDimensions, + 5, + ['Series A', 'Series B', 'Series C', 'Series D', 'Series E'], + LegendPosition.BOTTOM, + ); + + // Narrow panels should have more rows due to less items per row + expect(narrowConfig.requiredRows).toBeGreaterThanOrEqual( + wideConfig.requiredRows, + ); + }); + + it('should respect maximum legend height ratio for bottom legend', () => { + const config = calculateEnhancedLegendConfig( + mockDimensions, + 20, + Array.from({ length: 20 }, (_, i) => `Series ${i + 1}`), + LegendPosition.BOTTOM, + ); + + // The implementation uses absoluteMaxHeight of 80 + expect(config.calculatedHeight).toBeLessThanOrEqual(80); + }); + }); + + describe('Right Legend Configuration', () => { + it('should calculate correct configuration for right legend', () => { + const config = calculateEnhancedLegendConfig( + mockDimensions, + 5, + ['Series A', 'Series B', 'Series C', 'Series D', 'Series E'], + LegendPosition.RIGHT, + ); + + expect(config.calculatedWidth).toBeGreaterThan(0); + expect(config.minWidth).toBe(150); + expect(config.maxWidth).toBeLessThanOrEqual(400); + expect(config.calculatedWidth).toBeLessThanOrEqual( + mockDimensions.width * 0.3, + ); // maxLegendWidthRatio + expect(config.requiredRows).toBe(5); // Each series on its own row for right-side + }); + + it('should calculate width based on series label length for right legend', () => { + const shortLabels = ['A', 'B', 'C']; + const longLabels = [ + 'Very Long Series Name A', + 'Very Long Series Name B', + 'Very Long Series Name C', + ]; + + const shortConfig = calculateEnhancedLegendConfig( + mockDimensions, + 3, + shortLabels, + LegendPosition.RIGHT, + ); + + const longConfig = calculateEnhancedLegendConfig( + mockDimensions, + 3, + longLabels, + LegendPosition.RIGHT, + ); + + expect(longConfig.calculatedWidth).toBeGreaterThan( + shortConfig.calculatedWidth ?? 0, + ); + }); + + it('should handle scrollbar for right legend with many series', () => { + const tallDimensions: Dimensions = { width: 800, height: 200 }; + const manySeriesLabels = Array.from( + { length: 15 }, + (_, i) => `Series ${i + 1}`, + ); + + const config = calculateEnhancedLegendConfig( + tallDimensions, + 15, + manySeriesLabels, + LegendPosition.RIGHT, + ); + + expect(config.showScrollbar).toBe(true); + expect(config.calculatedHeight).toBeLessThanOrEqual(config.maxHeight); + }); + + it('should respect maximum width constraints for right legend', () => { + const narrowDimensions: Dimensions = { width: 400, height: 400 }; + + const config = calculateEnhancedLegendConfig( + narrowDimensions, + 5, + Array.from({ length: 5 }, (_, i) => `Very Long Series Name ${i + 1}`), + LegendPosition.RIGHT, + ); + + expect(config.calculatedWidth).toBeLessThanOrEqual( + narrowDimensions.width * 0.3, + ); + expect(config.calculatedWidth).toBeLessThanOrEqual(400); // absoluteMaxWidth + }); + }); + + describe('Edge Cases', () => { + it('should handle empty series labels', () => { + const config = calculateEnhancedLegendConfig( + mockDimensions, + 0, + [], + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.requiredRows).toBe(0); + }); + + it('should handle undefined series labels', () => { + const config = calculateEnhancedLegendConfig( + mockDimensions, + 3, + undefined, + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.requiredRows).toBe(1); // For 3 series, should be 1 row (logic only forces 2 rows when seriesCount > 3) + }); + + it('should handle very small dimensions', () => { + const smallDimensions: Dimensions = { width: 100, height: 100 }; + + const config = calculateEnhancedLegendConfig( + smallDimensions, + 3, + ['A', 'B', 'C'], + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.calculatedHeight).toBeLessThanOrEqual( + smallDimensions.height * 0.15, + ); + }); + }); + }); + + describe('applyEnhancedLegendStyling', () => { + let mockLegendElement: HTMLElement; + + beforeEach(() => { + mockLegendElement = document.createElement('div'); + mockLegendElement.className = 'u-legend'; + }); + + describe('Bottom Legend Styling', () => { + it('should apply correct classes for bottom legend', () => { + applyEnhancedLegendStyling( + mockLegendElement, + mockConfig, + 2, + LegendPosition.BOTTOM, + ); + + expect(mockLegendElement.classList.contains('u-legend-enhanced')).toBe( + true, + ); + expect(mockLegendElement.classList.contains('u-legend-bottom')).toBe(true); + expect(mockLegendElement.classList.contains('u-legend-right')).toBe(false); + expect(mockLegendElement.classList.contains('u-legend-multi-line')).toBe( + true, + ); + }); + + it('should apply single-line class for single row bottom legend', () => { + applyEnhancedLegendStyling( + mockLegendElement, + mockConfig, + 1, + LegendPosition.BOTTOM, + ); + + expect(mockLegendElement.classList.contains('u-legend-single-line')).toBe( + true, + ); + expect(mockLegendElement.classList.contains('u-legend-multi-line')).toBe( + false, + ); + }); + + it('should set correct height styles for bottom legend', () => { + applyEnhancedLegendStyling( + mockLegendElement, + mockConfig, + 2, + LegendPosition.BOTTOM, + ); + + expect(mockLegendElement.style.height).toBe('60px'); + expect(mockLegendElement.style.minHeight).toBe('46px'); + expect(mockLegendElement.style.maxHeight).toBe('80px'); + expect(mockLegendElement.style.width).toBe(''); + }); + }); + + describe('Right Legend Styling', () => { + it('should apply correct classes for right legend', () => { + applyEnhancedLegendStyling( + mockLegendElement, + mockConfig, + 5, + LegendPosition.RIGHT, + ); + + expect(mockLegendElement.classList.contains('u-legend-enhanced')).toBe( + true, + ); + expect(mockLegendElement.classList.contains('u-legend-right')).toBe(true); + expect(mockLegendElement.classList.contains('u-legend-bottom')).toBe(false); + expect(mockLegendElement.classList.contains('u-legend-right-aligned')).toBe( + true, + ); + }); + + it('should set correct width and height styles for right legend', () => { + applyEnhancedLegendStyling( + mockLegendElement, + mockConfig, + 5, + LegendPosition.RIGHT, + ); + + expect(mockLegendElement.style.width).toBe('200px'); + expect(mockLegendElement.style.minWidth).toBe('150px'); + expect(mockLegendElement.style.maxWidth).toBe('300px'); + expect(mockLegendElement.style.height).toBe('60px'); + expect(mockLegendElement.style.minHeight).toBe('46px'); + expect(mockLegendElement.style.maxHeight).toBe('80px'); + }); + }); + + describe('Scrollbar Styling', () => { + it('should add scrollable class when scrollbar is needed', () => { + const scrollableConfig = { ...mockConfig, showScrollbar: true }; + + applyEnhancedLegendStyling( + mockLegendElement, + scrollableConfig, + 5, + LegendPosition.BOTTOM, + ); + + expect(mockLegendElement.classList.contains('u-legend-scrollable')).toBe( + true, + ); + }); + + it('should remove scrollable class when scrollbar is not needed', () => { + mockLegendElement.classList.add('u-legend-scrollable'); + + applyEnhancedLegendStyling( + mockLegendElement, + mockConfig, + 2, + LegendPosition.BOTTOM, + ); + + expect(mockLegendElement.classList.contains('u-legend-scrollable')).toBe( + false, + ); + }); + }); + }); + + describe('Legend Responsive Distribution', () => { + describe('Items per row calculation', () => { + it('should calculate correct items per row for different panel widths', () => { + const testCases = [ + { width: 300, expectedMaxItemsPerRow: 2 }, + { width: 600, expectedMaxItemsPerRow: 4 }, + { width: 1200, expectedMaxItemsPerRow: 8 }, + ]; + + testCases.forEach(({ width, expectedMaxItemsPerRow }) => { + const dimensions: Dimensions = { width, height: 400 }; + const config = calculateEnhancedLegendConfig( + dimensions, + expectedMaxItemsPerRow + 2, // More series than can fit in one row + Array.from( + { length: expectedMaxItemsPerRow + 2 }, + (_, i) => `Series ${i + 1}`, + ), + LegendPosition.BOTTOM, + ); + + expect(config.requiredRows).toBeGreaterThan(1); + }); + }); + + it('should handle very long series names by adjusting layout', () => { + const longSeriesNames = [ + 'Very Long Series Name That Might Not Fit', + 'Another Extremely Long Series Name', + 'Yet Another Very Long Series Name', + ]; + + const config = calculateEnhancedLegendConfig( + { width: 400, height: 300 }, + 3, + longSeriesNames, + LegendPosition.BOTTOM, + ); + + // Should require more rows due to long names + expect(config.requiredRows).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Dynamic height adjustment', () => { + it('should adjust height based on number of required rows', () => { + const fewSeries = calculateEnhancedLegendConfig( + mockDimensions, + 2, + ['A', 'B'], + LegendPosition.BOTTOM, + ); + + const manySeries = calculateEnhancedLegendConfig( + mockDimensions, + 10, + Array.from({ length: 10 }, (_, i) => `Series ${i + 1}`), + LegendPosition.BOTTOM, + ); + + expect(manySeries.calculatedHeight).toBeGreaterThan( + fewSeries.calculatedHeight, + ); + }); + }); + }); + + describe('Legend Position Integration', () => { + it('should handle legend position changes correctly', () => { + const seriesLabels = [ + 'Series A', + 'Series B', + 'Series C', + 'Series D', + 'Series E', + ]; + + const bottomConfig = calculateEnhancedLegendConfig( + mockDimensions, + 5, + seriesLabels, + LegendPosition.BOTTOM, + ); + + const rightConfig = calculateEnhancedLegendConfig( + mockDimensions, + 5, + seriesLabels, + LegendPosition.RIGHT, + ); + + // Bottom legend should have width constraints, right legend should have height constraints + expect(bottomConfig.calculatedWidth).toBeUndefined(); + expect(rightConfig.calculatedWidth).toBeDefined(); + expect(rightConfig.calculatedWidth).toBeGreaterThan(0); + }); + + it('should apply different styling based on legend position', () => { + const mockElement = document.createElement('div'); + + // Test bottom positioning + applyEnhancedLegendStyling( + mockElement, + mockConfig, + 3, + LegendPosition.BOTTOM, + ); + + const hasBottomClasses = mockElement.classList.contains('u-legend-bottom'); + + // Reset element + mockElement.className = 'u-legend'; + + // Test right positioning + applyEnhancedLegendStyling(mockElement, mockConfig, 3, LegendPosition.RIGHT); + + const hasRightClasses = mockElement.classList.contains('u-legend-right'); + + expect(hasBottomClasses).toBe(true); + expect(hasRightClasses).toBe(true); + }); + }); + + describe('Performance and Edge Cases', () => { + it('should handle large number of series efficiently', () => { + const startTime = Date.now(); + + const largeSeries = Array.from({ length: 100 }, (_, i) => `Series ${i + 1}`); + const config = calculateEnhancedLegendConfig( + mockDimensions, + 100, + largeSeries, + LegendPosition.BOTTOM, + ); + + const endTime = Date.now(); + const executionTime = endTime - startTime; + + expect(executionTime).toBeLessThan(100); // Should complete within 100ms + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.showScrollbar).toBe(true); + }); + + it('should handle zero dimensions gracefully', () => { + const zeroDimensions: Dimensions = { width: 0, height: 0 }; + + const config = calculateEnhancedLegendConfig( + zeroDimensions, + 3, + ['A', 'B', 'C'], + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.minHeight).toBeGreaterThan(0); + }); + + it('should handle negative dimensions gracefully', () => { + const negativeDimensions: Dimensions = { width: -100, height: -100 }; + + const config = calculateEnhancedLegendConfig( + negativeDimensions, + 3, + ['A', 'B', 'C'], + LegendPosition.BOTTOM, + ); + + expect(config.calculatedHeight).toBeGreaterThan(0); + expect(config.minHeight).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/src/container/PanelWrapper/enhancedLegend.ts b/frontend/src/container/PanelWrapper/enhancedLegend.ts new file mode 100644 index 000000000000..948521593cd2 --- /dev/null +++ b/frontend/src/container/PanelWrapper/enhancedLegend.ts @@ -0,0 +1,246 @@ +import { Dimensions } from 'hooks/useDimensions'; +import { LegendPosition } from 'types/api/dashboard/getAll'; + +export interface EnhancedLegendConfig { + minHeight: number; + maxHeight: number; + calculatedHeight: number; + showScrollbar: boolean; + requiredRows: number; + // For right-side legend + minWidth?: number; + maxWidth?: number; + calculatedWidth?: number; +} + +/** + * Calculate legend configuration based on panel dimensions and series count + * Prioritizes chart space while ensuring legend usability + */ +// eslint-disable-next-line sonarjs/cognitive-complexity +export function calculateEnhancedLegendConfig( + dimensions: Dimensions, + seriesCount: number, + seriesLabels?: string[], + legendPosition: LegendPosition = LegendPosition.BOTTOM, +): EnhancedLegendConfig { + const lineHeight = 34; + const padding = 12; + const maxRowsToShow = 2; // Reduced from 3 to 2 for better chart/legend ratio + + // Different configurations for bottom vs right positioning + if (legendPosition === LegendPosition.RIGHT) { + // Right-side legend configuration + const maxLegendWidthRatio = 0.3; // Legend should not take more than 30% of panel width + const absoluteMaxWidth = Math.min( + 400, + dimensions.width * maxLegendWidthRatio, + ); + const minWidth = 150; + + // For right-side legend, calculate based on text length + const avgCharWidth = 8; + let avgTextLength = 15; + if (seriesLabels && seriesLabels.length > 0) { + const totalLength = seriesLabels.reduce( + (sum, label) => sum + Math.min(label.length, 40), + 0, + ); + avgTextLength = Math.max( + 10, + Math.min(35, totalLength / seriesLabels.length), + ); + } + + // Fix: Ensure width respects the ratio constraint even if it's less than minWidth + const estimatedWidth = 80 + avgCharWidth * avgTextLength; + const calculatedWidth = Math.min( + Math.max(minWidth, estimatedWidth), + absoluteMaxWidth, + ); + + // For right-side legend, height can be more flexible + const maxHeight = dimensions.height - 40; // Leave some padding + const idealHeight = seriesCount * lineHeight + padding; + const calculatedHeight = Math.min(idealHeight, maxHeight); + const showScrollbar = idealHeight > calculatedHeight; + + return { + minHeight: lineHeight + padding, + maxHeight, + calculatedHeight, + showScrollbar, + requiredRows: seriesCount, // Each series on its own row for right-side + minWidth, + maxWidth: absoluteMaxWidth, + calculatedWidth, + }; + } + + // Bottom legend configuration (existing logic) + const maxLegendRatio = 0.15; + // Fix: For very small dimensions, respect the ratio instead of using fixed 80px minimum + const ratioBasedMaxHeight = dimensions.height * maxLegendRatio; + + // Handle edge cases and calculate absolute max height + let absoluteMaxHeight; + if (dimensions.height <= 0) { + absoluteMaxHeight = 46; // Fallback for invalid dimensions + } else if (dimensions.height <= 400) { + // For small to medium panels, prioritize ratio constraint + absoluteMaxHeight = Math.min(80, Math.max(15, ratioBasedMaxHeight)); + } else { + // For larger panels, maintain a reasonable minimum + absoluteMaxHeight = Math.min(80, Math.max(20, ratioBasedMaxHeight)); + } + + const baseItemWidth = 44; + const avgCharWidth = 8; + + let avgTextLength = 15; + if (seriesLabels && seriesLabels.length > 0) { + const totalLength = seriesLabels.reduce( + (sum, label) => sum + Math.min(label.length, 30), + 0, + ); + avgTextLength = Math.max(8, Math.min(25, totalLength / seriesLabels.length)); + } + + // Estimate item width based on actual or estimated text length + let estimatedItemWidth = baseItemWidth + avgCharWidth * avgTextLength; + + // For very wide panels, allow longer text + if (dimensions.width > 800) { + estimatedItemWidth = Math.max( + estimatedItemWidth, + baseItemWidth + avgCharWidth * 22, + ); + } else if (dimensions.width < 400) { + estimatedItemWidth = Math.min( + estimatedItemWidth, + baseItemWidth + avgCharWidth * 14, + ); + } + + // Calculate items per row based on available width + const availableWidth = dimensions.width - padding * 2; + const itemsPerRow = Math.max( + 1, + Math.floor(availableWidth / estimatedItemWidth), + ); + let requiredRows = Math.ceil(seriesCount / itemsPerRow); + + if (requiredRows === 1 && seriesCount > 3) { + requiredRows = 2; + } + + // Calculate heights + const idealHeight = requiredRows * lineHeight + padding; + + // For single row, use minimal height + let minHeight; + if (requiredRows <= 1) { + minHeight = lineHeight + padding; // Single row + } else { + // Multiple rows: show 2 rows max, then scroll + minHeight = Math.min(2 * lineHeight + padding, idealHeight); + } + + // For very small dimensions, allow the minHeight to be smaller to respect ratio constraints + if (dimensions.height < 200) { + minHeight = Math.min(minHeight, absoluteMaxHeight); + } + + // Maximum height constraint - prioritize chart space + // Fix: Ensure we respect the ratio-based constraint for small dimensions + const rowBasedMaxHeight = maxRowsToShow * lineHeight + padding; + const maxHeight = Math.min(rowBasedMaxHeight, absoluteMaxHeight); + + const calculatedHeight = Math.max(minHeight, Math.min(idealHeight, maxHeight)); + const showScrollbar = idealHeight > calculatedHeight; + + return { + minHeight, + maxHeight, + calculatedHeight, + showScrollbar, + requiredRows, + }; +} + +// CSS class constants +const LEGEND_SINGLE_LINE_CLASS = 'u-legend-single-line'; +const LEGEND_MULTI_LINE_CLASS = 'u-legend-multi-line'; +const LEGEND_RIGHT_ALIGNED_CLASS = 'u-legend-right-aligned'; + +/** + * Apply enhanced legend styling to a legend element + */ +export function applyEnhancedLegendStyling( + legend: HTMLElement, + config: EnhancedLegendConfig, + requiredRows: number, + legendPosition: LegendPosition = LegendPosition.BOTTOM, +): void { + const legendElement = legend; + legendElement.classList.add('u-legend-enhanced'); + + // Apply position-specific styling + if (legendPosition === LegendPosition.RIGHT) { + legendElement.classList.add('u-legend-right'); + legendElement.classList.remove('u-legend-bottom'); + + // Set width for right-side legend + if (config.calculatedWidth) { + legendElement.style.width = `${config.calculatedWidth}px`; + legendElement.style.minWidth = `${config.minWidth}px`; + legendElement.style.maxWidth = `${config.maxWidth}px`; + } + + // Height for right-side legend + legendElement.style.height = `${config.calculatedHeight}px`; + legendElement.style.minHeight = `${config.minHeight}px`; + legendElement.style.maxHeight = `${config.maxHeight}px`; + } else { + legendElement.classList.add('u-legend-bottom'); + legendElement.classList.remove('u-legend-right'); + + // Height for bottom legend + legendElement.style.height = `${config.calculatedHeight}px`; + legendElement.style.minHeight = `${config.minHeight}px`; + legendElement.style.maxHeight = `${config.maxHeight}px`; + + // Reset width for bottom legend + legendElement.style.width = ''; + legendElement.style.minWidth = ''; + legendElement.style.maxWidth = ''; + } + + // Apply alignment based on position and number of rows + if (legendPosition === LegendPosition.RIGHT) { + legendElement.classList.add(LEGEND_RIGHT_ALIGNED_CLASS); + legendElement.classList.remove( + LEGEND_SINGLE_LINE_CLASS, + LEGEND_MULTI_LINE_CLASS, + ); + } else if (requiredRows === 1) { + legendElement.classList.add(LEGEND_SINGLE_LINE_CLASS); + legendElement.classList.remove( + LEGEND_MULTI_LINE_CLASS, + LEGEND_RIGHT_ALIGNED_CLASS, + ); + } else { + legendElement.classList.add(LEGEND_MULTI_LINE_CLASS); + legendElement.classList.remove( + LEGEND_SINGLE_LINE_CLASS, + LEGEND_RIGHT_ALIGNED_CLASS, + ); + } + + // Add scrollbar indicator if needed + if (config.showScrollbar) { + legendElement.classList.add('u-legend-scrollable'); + } else { + legendElement.classList.remove('u-legend-scrollable'); + } +} diff --git a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts index 58b1dc00ce0e..f9f847032874 100644 --- a/frontend/src/lib/uPlotLib/getUplotChartOptions.ts +++ b/frontend/src/lib/uPlotLib/getUplotChartOptions.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/ban-ts-comment */ // @ts-nocheck @@ -8,10 +9,16 @@ import { PANEL_TYPES } from 'constants/queryBuilder'; import { FullViewProps } from 'container/GridCardLayout/GridCard/FullView/types'; import { saveLegendEntriesToLocalStorage } from 'container/GridCardLayout/GridCard/FullView/utils'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; +import { + applyEnhancedLegendStyling, + calculateEnhancedLegendConfig, +} from 'container/PanelWrapper/enhancedLegend'; import { Dimensions } from 'hooks/useDimensions'; import { convertValue } from 'lib/getConvertedValue'; +import getLabelName from 'lib/getLabelName'; import { cloneDeep, isUndefined } from 'lodash-es'; import _noop from 'lodash-es/noop'; +import { LegendPosition } from 'types/api/dashboard/getAll'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery'; @@ -60,6 +67,8 @@ export interface GetUPlotChartOptions { customSeries?: (data: QueryData[]) => uPlot.Series[]; isLogScale?: boolean; colorMapping?: Record; + enhancedLegend?: boolean; + legendPosition?: LegendPosition; enableZoom?: boolean; } @@ -169,6 +178,8 @@ export const getUPlotChartOptions = ({ customSeries, isLogScale, colorMapping, + enhancedLegend = true, + legendPosition = LegendPosition.BOTTOM, enableZoom, }: GetUPlotChartOptions): uPlot.Options => { const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale); @@ -182,10 +193,42 @@ export const getUPlotChartOptions = ({ const bands = stackBarChart ? getBands(series) : null; + // Calculate dynamic legend configuration based on panel dimensions and series count + const seriesCount = (apiResponse?.data?.result || []).length; + const seriesLabels = enhancedLegend + ? (apiResponse?.data?.result || []).map((item) => + getLabelName(item.metric || {}, item.queryName || '', item.legend || ''), + ) + : []; + const legendConfig = enhancedLegend + ? calculateEnhancedLegendConfig( + dimensions, + seriesCount, + seriesLabels, + legendPosition, + ) + : { + calculatedHeight: 30, + minHeight: 30, + maxHeight: 30, + itemsPerRow: 3, + showScrollbar: false, + }; + + // Calculate chart dimensions based on legend position + const chartWidth = + legendPosition === LegendPosition.RIGHT && legendConfig.calculatedWidth + ? dimensions.width - legendConfig.calculatedWidth - 10 + : dimensions.width; + const chartHeight = + legendPosition === LegendPosition.BOTTOM + ? dimensions.height - legendConfig.calculatedHeight - 10 + : dimensions.height; + return { id, - width: dimensions.width, - height: dimensions.height - 30, + width: chartWidth, + height: chartHeight, legend: { show: true, live: false, @@ -353,13 +396,166 @@ export const getUPlotChartOptions = ({ ], ready: [ (self): void => { + // Add CSS classes to the uPlot container based on legend position + const uplotContainer = self.root; + if (uplotContainer) { + uplotContainer.classList.remove( + 'u-plot-right-legend', + 'u-plot-bottom-legend', + ); + if (legendPosition === LegendPosition.RIGHT) { + uplotContainer.classList.add('u-plot-right-legend'); + } else { + uplotContainer.classList.add('u-plot-bottom-legend'); + } + } + const legend = self.root.querySelector('.u-legend'); if (legend) { + // Apply enhanced legend styling + if (enhancedLegend) { + applyEnhancedLegendStyling( + legend as HTMLElement, + legendConfig, + legendConfig.requiredRows, + legendPosition, + ); + } + + // Global cleanup function for all legend tooltips + const cleanupAllTooltips = (): void => { + const existingTooltips = document.querySelectorAll('.legend-tooltip'); + existingTooltips.forEach((tooltip) => tooltip.remove()); + }; + + // Add single global cleanup listener for this chart + const globalCleanupHandler = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + if ( + !target.closest('.u-legend') && + !target.classList.contains('legend-tooltip') + ) { + cleanupAllTooltips(); + } + }; + document.addEventListener('mousemove', globalCleanupHandler); + + // Store cleanup function for potential removal later + (self as any)._tooltipCleanup = (): void => { + cleanupAllTooltips(); + document.removeEventListener('mousemove', globalCleanupHandler); + }; + const seriesEls = legend.querySelectorAll('.u-series'); const seriesArray = Array.from(seriesEls); seriesArray.forEach((seriesEl, index) => { - seriesEl.addEventListener('click', () => { - if (stackChart) { + // Add tooltip and proper text wrapping for legends + const thElement = seriesEl.querySelector('th'); + if (thElement && seriesLabels[index]) { + // Store the original marker element before clearing + const markerElement = thElement.querySelector('.u-marker'); + const markerClone = markerElement + ? (markerElement.cloneNode(true) as HTMLElement) + : null; + + // Get the current text content + const legendText = seriesLabels[index]; + + // Clear the th content and rebuild it + thElement.innerHTML = ''; + + // Add back the marker + if (markerClone) { + thElement.appendChild(markerClone); + } + + // Create text wrapper + const textSpan = document.createElement('span'); + textSpan.className = 'legend-text'; + textSpan.textContent = legendText; + thElement.appendChild(textSpan); + + // Setup tooltip functionality - check truncation on hover + let tooltipElement: HTMLElement | null = null; + let isHovering = false; + + const showTooltip = (e: MouseEvent): void => { + // Check if text is actually truncated at the time of hover + const isTextTruncated = (): boolean => { + // For right-side legends, check if text overflows the container + if (legendPosition === LegendPosition.RIGHT) { + return textSpan.scrollWidth > textSpan.clientWidth; + } + // For bottom legends, check if text is longer than reasonable display length + return legendText.length > 20; + }; + + // Only show tooltip if text is actually truncated + if (!isTextTruncated()) { + return; + } + + isHovering = true; + + // Clean up any existing tooltips first + cleanupAllTooltips(); + + // Small delay to ensure cleanup is complete and DOM is ready + setTimeout(() => { + if (!isHovering) return; // Don't show if mouse already left + + // Double-check no tooltip exists + if (document.querySelector('.legend-tooltip')) { + return; + } + + // Create tooltip element + tooltipElement = document.createElement('div'); + tooltipElement.className = 'legend-tooltip'; + tooltipElement.textContent = legendText; + tooltipElement.style.cssText = ` + position: fixed; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + z-index: 10000; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + border: 1px solid #374151; + `; + + // Position tooltip near cursor + const rect = (e.target as HTMLElement).getBoundingClientRect(); + tooltipElement.style.left = `${e.clientX + 10}px`; + tooltipElement.style.top = `${rect.top - 35}px`; + + document.body.appendChild(tooltipElement); + }, 15); + }; + + const hideTooltip = (): void => { + isHovering = false; + + // Simple cleanup with a reasonable delay + setTimeout(() => { + if (!isHovering && tooltipElement) { + tooltipElement.remove(); + tooltipElement = null; + } + }, 200); + }; + + // Simple tooltip events + thElement.addEventListener('mouseenter', showTooltip); + thElement.addEventListener('mouseleave', hideTooltip); + + // Add click handlers for marker and text separately + const currentMarker = thElement.querySelector('.u-marker'); + const textElement = thElement.querySelector('.legend-text'); + + // Helper function to handle stack chart logic + const handleStackChart = (): void => { setHiddenGraph((prev) => { if (isUndefined(prev)) { return { [index]: true }; @@ -369,30 +565,71 @@ export const getUPlotChartOptions = ({ } return { [index]: true }; }); - } - if (graphsVisibilityStates) { - setGraphsVisibilityStates?.((prev) => { - const newGraphVisibilityStates = [...prev]; - if ( - newGraphVisibilityStates[index + 1] && - newGraphVisibilityStates.every((value, i) => - i === index + 1 ? value : !value, - ) - ) { - newGraphVisibilityStates.fill(true); - } else { - newGraphVisibilityStates.fill(false); - newGraphVisibilityStates[index + 1] = true; + }; + + // Marker click handler - checkbox behavior (toggle individual series) + if (currentMarker) { + currentMarker.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent event bubbling to text handler + + if (stackChart) { + handleStackChart(); + } + if (graphsVisibilityStates) { + setGraphsVisibilityStates?.((prev) => { + const newGraphVisibilityStates = [...prev]; + // Toggle the specific series visibility (checkbox behavior) + newGraphVisibilityStates[index + 1] = !newGraphVisibilityStates[ + index + 1 + ]; + + saveLegendEntriesToLocalStorage({ + options: self, + graphVisibilityState: newGraphVisibilityStates, + name: id || '', + }); + return newGraphVisibilityStates; + }); } - saveLegendEntriesToLocalStorage({ - options: self, - graphVisibilityState: newGraphVisibilityStates, - name: id || '', - }); - return newGraphVisibilityStates; }); } - }); + + // Text click handler - show only/show all behavior (existing behavior) + if (textElement) { + textElement.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent event bubbling + + if (stackChart) { + handleStackChart(); + } + if (graphsVisibilityStates) { + setGraphsVisibilityStates?.((prev) => { + const newGraphVisibilityStates = [...prev]; + // Show only this series / show all behavior + if ( + newGraphVisibilityStates[index + 1] && + newGraphVisibilityStates.every((value, i) => + i === index + 1 ? value : !value, + ) + ) { + // If only this series is visible, show all + newGraphVisibilityStates.fill(true); + } else { + // Otherwise, show only this series + newGraphVisibilityStates.fill(false); + newGraphVisibilityStates[index + 1] = true; + } + saveLegendEntriesToLocalStorage({ + options: self, + graphVisibilityState: newGraphVisibilityStates, + name: id || '', + }); + return newGraphVisibilityStates; + }); + } + }); + } + } }); } }, @@ -412,6 +649,7 @@ export const getUPlotChartOptions = ({ stackBarChart, hiddenGraph, isDarkMode, + colorMapping, }), axes: getAxes({ isDarkMode, yAxisUnit, panelType, isLogScale }), }; diff --git a/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts b/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts index a955d787ac7d..cf9ca032210c 100644 --- a/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts +++ b/frontend/src/lib/uPlotLib/utils/tests/getUplotChartOptions.test.ts @@ -25,11 +25,44 @@ describe('getUPlotChartOptions', () => { const options = getUPlotChartOptions(inputPropsTimeSeries); expect(options.legend?.isolate).toBe(true); expect(options.width).toBe(inputPropsTimeSeries.dimensions.width); - expect(options.height).toBe(inputPropsTimeSeries.dimensions.height - 30); expect(options.axes?.length).toBe(2); expect(options.series[1].label).toBe('A'); }); + test('should return enhanced legend options when enabled', () => { + const options = getUPlotChartOptions({ + ...inputPropsTimeSeries, + enhancedLegend: true, + legendPosition: 'bottom' as any, + }); + expect(options.legend?.isolate).toBe(true); + expect(options.legend?.show).toBe(true); + expect(options.hooks?.ready).toBeDefined(); + expect(Array.isArray(options.hooks?.ready)).toBe(true); + }); + + test('should adjust chart dimensions for right legend position', () => { + const options = getUPlotChartOptions({ + ...inputPropsTimeSeries, + enhancedLegend: true, + legendPosition: 'right' as any, + }); + expect(options.legend?.isolate).toBe(true); + expect(options.width).toBeLessThan(inputPropsTimeSeries.dimensions.width); + expect(options.height).toBe(inputPropsTimeSeries.dimensions.height); + }); + + test('should adjust chart dimensions for bottom legend position', () => { + const options = getUPlotChartOptions({ + ...inputPropsTimeSeries, + enhancedLegend: true, + legendPosition: 'bottom' as any, + }); + expect(options.legend?.isolate).toBe(true); + expect(options.width).toBe(inputPropsTimeSeries.dimensions.width); + expect(options.height).toBeLessThan(inputPropsTimeSeries.dimensions.height); + }); + test('Should return line chart as drawStyle for time series', () => { const options = getUPlotChartOptions(inputPropsTimeSeries); // @ts-ignore diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 0f574942cfaf..3dd190128c61 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -17,12 +17,12 @@ body { } .u-legend { - max-height: 30px; // slicing the height of the widget Header height ; + max-height: 30px; // Default height for backward compatibility overflow-y: auto; overflow-x: hidden; &::-webkit-scrollbar { - width: 0.3rem; + width: 0.5rem; } &::-webkit-scrollbar-corner { background: transparent; @@ -53,6 +53,313 @@ body { text-decoration-thickness: 3px; } } + + // Enhanced legend styles + &.u-legend-enhanced { + max-height: none; // Remove default max-height restriction + padding: 6px 4px; // Back to original padding + + // Thin and neat scrollbar for enhanced legend + &::-webkit-scrollbar { + width: 0.25rem; + height: 0.25rem; + } + &::-webkit-scrollbar-thumb { + background: rgba(136, 136, 136, 0.4); + border-radius: 0.125rem; + + &:hover { + background: rgba(136, 136, 136, 0.7); + } + } + &::-webkit-scrollbar-track { + background: transparent; + } + + // Enhanced table layout for better responsiveness + table { + width: 100%; + table-layout: fixed; + } + + tbody { + display: flex; + flex-wrap: wrap; + gap: 1px 2px; + align-items: center; + justify-content: flex-start; + width: 100%; + } + + // Center alignment for single-line legends + &.u-legend-single-line tbody { + justify-content: center; + } + + &.u-legend-right-aligned { + tbody { + align-items: flex-start !important; + justify-content: flex-start !important; + } + + tr.u-series { + justify-content: flex-start !important; + + th { + justify-content: flex-start !important; + text-align: left !important; + + .legend-text { + text-align: left !important; + } + } + } + } + + // Right-side legend specific styles + &.u-legend-right { + tbody { + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + justify-content: flex-start; + gap: 2px; + } + + tr.u-series { + width: 100%; + + th { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + justify-content: flex-start; + cursor: pointer; + position: relative; + min-width: 0; + width: 100%; + + .u-marker { + border-radius: 50%; + min-width: 11px; + min-height: 11px; + width: 11px; + height: 11px; + flex-shrink: 0; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + + &:hover { + transform: scale(1.2); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3); + } + + &:active { + transform: scale(0.9); + } + } + + // Text container for proper ellipsis + .legend-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + min-width: 0; + flex: 1; + padding-bottom: 2px; + } + + // Tooltip styling + &[title] { + cursor: pointer; + } + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + } + + &.u-off { + opacity: 0.5; + text-decoration: line-through; + text-decoration-thickness: 1px; + + th { + &:hover { + opacity: 0.7; + } + + .u-marker { + opacity: 0.3; + position: relative; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 12px; + height: 2px; + background: #ff4444; + transform: translate(-50%, -50%) rotate(45deg); + border-radius: 1px; + } + + &:hover { + opacity: 0.6; + } + } + } + } + + // Focus styles for keyboard navigation + &:focus { + outline: 1px solid rgba(66, 165, 245, 0.8); + outline-offset: 1px; + } + } + } + + // Bottom legend specific styles + &.u-legend-bottom { + tbody { + flex-direction: row; + flex-wrap: wrap; + } + } + + &.u-legend-bottom tr.u-series { + display: flex; + flex: 0 0 auto; + min-width: fit-content; + max-width: 200px; // Limit width to enable truncation + + th { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + padding: 6px 10px; + cursor: pointer; + white-space: nowrap; + -webkit-font-smoothing: antialiased; + border-radius: 2px; + min-width: 0; // Allow shrinking + max-width: 100%; + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + + .u-marker { + border-radius: 50%; + min-width: 11px; + min-height: 11px; + width: 11px; + height: 11px; + flex-shrink: 0; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + + &:hover { + transform: scale(1.2); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3); + } + + &:active { + transform: scale(0.9); + } + } + + .legend-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + min-width: 0; + flex: 1; + padding-bottom: 2px; + } + + // Tooltip styling + &[title] { + cursor: pointer; + } + } + + &.u-off { + opacity: 0.5; + text-decoration: line-through; + text-decoration-thickness: 1px; + + th { + &:hover { + opacity: 0.7; + } + + .u-marker { + opacity: 0.3; + position: relative; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 12px; + height: 2px; + background: #ff4444; + transform: translate(-50%, -50%) rotate(45deg); + border-radius: 1px; + } + + &:hover { + opacity: 0.6; + } + } + } + } + + // Focus styles for keyboard navigation + &:focus { + outline: 1px solid rgba(66, 165, 245, 0.8); + outline-offset: 1px; + } + } + } +} + +// uPlot container adjustments for right-side legend +.uplot { + &.u-plot-right-legend { + display: flex; + flex-direction: row; + + .u-over { + flex: 1; + } + + .u-legend { + flex-shrink: 0; + margin-top: 0; + margin-bottom: 0; + } + } + + &.u-plot-bottom-legend { + display: flex; + flex-direction: column; + + .u-legend { + margin-top: 10px; + margin-left: 0; + margin-right: 0; + } + } } /* Style the selected background */ @@ -250,6 +557,94 @@ body { } } } + + // Enhanced legend light mode styles + .u-legend-enhanced { + // Light mode scrollbar styling + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + + &:hover { + background: rgba(0, 0, 0, 0.4); + } + } + + &.u-legend-bottom tr.u-series { + th { + &:hover { + background: rgba(0, 0, 0, 0.05); + } + } + + &.u-off { + opacity: 0.5; + text-decoration: line-through; + text-decoration-thickness: 1px; + + th { + &:hover { + background: rgba(0, 0, 0, 0.08); + opacity: 0.7; + } + + .u-marker { + opacity: 0.3; + + &::after { + background: #cc3333; + } + + &:hover { + opacity: 0.6; + } + } + } + } + + // Light mode focus styles + &:focus { + outline: 1px solid rgba(25, 118, 210, 0.8); + } + } + + &.u-legend-right tr.u-series { + th { + &:hover { + background: rgba(0, 0, 0, 0.05); + } + } + + &.u-off { + opacity: 0.5; + text-decoration: line-through; + text-decoration-thickness: 1px; + + th { + &:hover { + background: rgba(0, 0, 0, 0.08); + opacity: 0.7; + } + + .u-marker { + opacity: 0.3; + + &::after { + background: #cc3333; + } + + &:hover { + opacity: 0.6; + } + } + } + } + + // Light mode focus styles + &:focus { + outline: 1px solid rgba(25, 118, 210, 0.8); + } + } + } } .ant-notification-notice-message { @@ -320,3 +715,30 @@ notifications - 2050 .animate-spin { animation: spin 1s linear infinite; } + +// Custom legend tooltip for immediate display +.legend-tooltip { + position: fixed; + background: var(--bg-slate-400); + color: var(--text-vanilla-100); + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-family: 'Geist Mono'; + font-weight: 500; + z-index: 10000; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + border: 1px solid #374151; + -webkit-font-smoothing: antialiased; + letter-spacing: 0.025em; +} + +// Light mode styling for legend tooltip +.lightMode .legend-tooltip { + background: #ffffff; + color: #1f2937; + border: 1px solid #d1d5db; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} diff --git a/frontend/src/types/api/dashboard/getAll.ts b/frontend/src/types/api/dashboard/getAll.ts index 65e26d0cbb25..d9bfb1af43f0 100644 --- a/frontend/src/types/api/dashboard/getAll.ts +++ b/frontend/src/types/api/dashboard/getAll.ts @@ -17,6 +17,11 @@ export type TVariableQueryType = typeof VariableQueryTypeArr[number]; export const VariableSortTypeArr = ['DISABLED', 'ASC', 'DESC'] as const; export type TSortVariableValuesType = typeof VariableSortTypeArr[number]; +export enum LegendPosition { + BOTTOM = 'bottom', + RIGHT = 'right', +} + export interface IDashboardVariable { id: string; order?: any; @@ -111,6 +116,7 @@ export interface IBaseWidget { selectedTracesFields: BaseAutocompleteData[] | null; isLogScale?: boolean; columnWidths?: Record; + legendPosition?: LegendPosition; } export interface Widgets extends IBaseWidget { query: Query; From 8990fb7a7300dff265d3c3a83bb042388951c11e Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Tue, 27 May 2025 14:49:35 +0530 Subject: [PATCH 16/24] feat: allow custom color pallete in panel for legends (#8063) --- .../NewWidget/LeftContainer/index.tsx | 10 +- .../LegendColors/LegendColors.styles.scss | 169 +++++++++++++++ .../LegendColors/LegendColors.tsx | 205 ++++++++++++++++++ .../RightContainer/RightContainer.styles.scss | 4 + .../NewWidget/RightContainer/constants.ts | 14 ++ .../NewWidget/RightContainer/index.tsx | 26 +++ frontend/src/container/NewWidget/index.tsx | 20 ++ frontend/src/container/NewWidget/types.ts | 5 + .../PanelWrapper/PiePanelWrapper.tsx | 21 +- .../PanelWrapper/UplotPanelWrapper.tsx | 2 + .../src/lib/uPlotLib/utils/getSeriesData.ts | 12 +- frontend/src/types/api/dashboard/getAll.ts | 1 + 12 files changed, 476 insertions(+), 13 deletions(-) create mode 100644 frontend/src/container/NewWidget/RightContainer/LegendColors/LegendColors.styles.scss create mode 100644 frontend/src/container/NewWidget/RightContainer/LegendColors/LegendColors.tsx diff --git a/frontend/src/container/NewWidget/LeftContainer/index.tsx b/frontend/src/container/NewWidget/LeftContainer/index.tsx index 83d99aefcffe..6b72e6a6ad79 100644 --- a/frontend/src/container/NewWidget/LeftContainer/index.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/index.tsx @@ -6,7 +6,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useDashboard } from 'providers/Dashboard/Dashboard'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import { GlobalReducer } from 'types/reducer/globalTime'; @@ -27,6 +27,7 @@ function LeftContainer({ requestData, setRequestData, isLoadingPanelData, + setQueryResponse, }: WidgetGraphProps): JSX.Element { const { stagedQuery } = useQueryBuilder(); const { selectedDashboard } = useDashboard(); @@ -49,6 +50,13 @@ function LeftContainer({ }, ); + // Update parent component with query response for legend colors + useEffect(() => { + if (setQueryResponse) { + setQueryResponse(queryResponse); + } + }, [queryResponse, setQueryResponse]); + return ( <> (null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useEffect(() => { + const checkOverflow = (): void => { + if (textRef.current) { + const isTextOverflowing = + textRef.current.scrollWidth > textRef.current.clientWidth; + setIsOverflowing(isTextOverflowing); + } + }; + + checkOverflow(); + // Check on window resize + window.addEventListener('resize', checkOverflow); + return (): void => window.removeEventListener('resize', checkOverflow); + }, [label]); + + return ( + + + {label} + + + ); +} + +interface LegendColorsProps { + customLegendColors: Record; + setCustomLegendColors: Dispatch>>; + queryResponse?: UseQueryResult< + SuccessResponse, + Error + >; +} + +function LegendColors({ + customLegendColors, + setCustomLegendColors, + queryResponse = null as any, +}: LegendColorsProps): JSX.Element { + const { currentQuery } = useQueryBuilder(); + const isDarkMode = useIsDarkMode(); + + // Get legend labels from query response or current query + const legendLabels = useMemo(() => { + if (queryResponse?.data?.payload?.data?.result) { + return queryResponse.data.payload.data.result.map((item: any) => + getLabelName(item.metric || {}, item.queryName || '', item.legend || ''), + ); + } + + // Fallback to query data if no response available + return currentQuery.builder.queryData.map((query) => + getLabelName({}, query.queryName || '', query.legend || ''), + ); + }, [queryResponse, currentQuery]); + + // Get current or default color for a legend + const getColorForLegend = (label: string): string => { + if (customLegendColors[label]) { + return customLegendColors[label]; + } + return generateColor( + label, + isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor, + ); + }; + + // Handle color change + const handleColorChange = (label: string, color: string): void => { + setCustomLegendColors((prev) => ({ + ...prev, + [label]: color, + })); + }; + + // Reset to default color + const resetToDefault = (label: string): void => { + setCustomLegendColors((prev) => { + const updated = { ...prev }; + delete updated[label]; + return updated; + }); + }; + + // Reset all colors to default + const resetAllColors = (): void => { + setCustomLegendColors({}); + }; + + const items = [ + { + key: 'legend-colors', + label: ( +
+ + Legend Colors +
+ ), + children: ( +
+ {legendLabels.length === 0 ? ( + + No legends available. Run a query to see legend options. + + ) : ( + <> +
+ +
+
+ {legendLabels.map((label: string) => ( +
+ + handleColorChange(label, color.toHexString()) + } + size="small" + showText={false} + trigger="click" + > +
+
+
+ +
+ {customLegendColors[label] && ( +
+ { + e.stopPropagation(); + resetToDefault(label); + }} + > + Reset + +
+ )} +
+ +
+ ))} +
+ + )} +
+ ), + }, + ]; + + return ( +
+ +
+ ); +} + +LegendColors.defaultProps = { + queryResponse: null, +}; + +export default LegendColors; diff --git a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss index a32fcb885033..1c3b78494ab9 100644 --- a/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss +++ b/frontend/src/container/NewWidget/RightContainer/RightContainer.styles.scss @@ -174,6 +174,10 @@ gap: 8px; } + .legend-colors { + margin-top: 16px; + } + .panel-time-text { margin-top: 16px; color: var(--bg-vanilla-400); diff --git a/frontend/src/container/NewWidget/RightContainer/constants.ts b/frontend/src/container/NewWidget/RightContainer/constants.ts index 3735b684a59a..53aa7eae999c 100644 --- a/frontend/src/container/NewWidget/RightContainer/constants.ts +++ b/frontend/src/container/NewWidget/RightContainer/constants.ts @@ -164,3 +164,17 @@ export const panelTypeVsLegendPosition: { [PANEL_TYPES.HISTOGRAM]: false, [PANEL_TYPES.EMPTY_WIDGET]: false, } as const; + +export const panelTypeVsLegendColors: { + [key in PANEL_TYPES]: boolean; +} = { + [PANEL_TYPES.TIME_SERIES]: true, + [PANEL_TYPES.VALUE]: false, + [PANEL_TYPES.TABLE]: false, + [PANEL_TYPES.LIST]: false, + [PANEL_TYPES.PIE]: true, + [PANEL_TYPES.BAR]: true, + [PANEL_TYPES.TRACE]: false, + [PANEL_TYPES.HISTOGRAM]: true, + [PANEL_TYPES.EMPTY_WIDGET]: false, +} as const; diff --git a/frontend/src/container/NewWidget/RightContainer/index.tsx b/frontend/src/container/NewWidget/RightContainer/index.tsx index ac7f0fede5fb..f0e518ab06f5 100644 --- a/frontend/src/container/NewWidget/RightContainer/index.tsx +++ b/frontend/src/container/NewWidget/RightContainer/index.tsx @@ -30,11 +30,14 @@ import { useRef, useState, } from 'react'; +import { UseQueryResult } from 'react-query'; +import { SuccessResponse } from 'types/api'; import { ColumnUnit, LegendPosition, Widgets, } from 'types/api/dashboard/getAll'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { DataSource } from 'types/common/queryBuilder'; import { popupContainer } from 'utils/selectPopupContainer'; @@ -44,6 +47,7 @@ import { panelTypeVsColumnUnitPreferences, panelTypeVsCreateAlert, panelTypeVsFillSpan, + panelTypeVsLegendColors, panelTypeVsLegendPosition, panelTypeVsLogScale, panelTypeVsPanelTimePreferences, @@ -52,6 +56,7 @@ import { panelTypeVsThreshold, panelTypeVsYAxisUnit, } from './constants'; +import LegendColors from './LegendColors/LegendColors'; import ThresholdSelector from './Threshold/ThresholdSelector'; import { ThresholdProps } from './Threshold/types'; import { timePreferance } from './timeItems'; @@ -105,6 +110,9 @@ function RightContainer({ setIsLogScale, legendPosition, setLegendPosition, + customLegendColors, + setCustomLegendColors, + queryResponse, }: RightContainerProps): JSX.Element { const { selectedDashboard } = useDashboard(); const [inputValue, setInputValue] = useState(title); @@ -136,6 +144,7 @@ function RightContainer({ const allowPanelTimePreference = panelTypeVsPanelTimePreferences[selectedGraph]; const allowLegendPosition = panelTypeVsLegendPosition[selectedGraph]; + const allowLegendColors = panelTypeVsLegendColors[selectedGraph]; const allowPanelColumnPreference = panelTypeVsColumnUnitPreferences[selectedGraph]; @@ -462,6 +471,16 @@ function RightContainer({ )} + + {allowLegendColors && ( +
+ +
+ )} {allowCreateAlerts && ( @@ -529,10 +548,17 @@ interface RightContainerProps { setIsLogScale: Dispatch>; legendPosition: LegendPosition; setLegendPosition: Dispatch>; + customLegendColors: Record; + setCustomLegendColors: Dispatch>>; + queryResponse?: UseQueryResult< + SuccessResponse, + Error + >; } RightContainer.defaultProps = { selectedWidget: undefined, + queryResponse: null, }; export default RightContainer; diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index 06d73f575118..af6b3cda35de 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -34,9 +34,11 @@ import { } from 'providers/Dashboard/util'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { UseQueryResult } from 'react-query'; import { useSelector } from 'react-redux'; import { generatePath, useParams } from 'react-router-dom'; import { AppState } from 'store/reducers'; +import { SuccessResponse } from 'types/api'; import { ColumnUnit, Dashboard, @@ -44,6 +46,7 @@ import { Widgets, } from 'types/api/dashboard/getAll'; import { IField } from 'types/api/logs/fields'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; @@ -191,6 +194,10 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { const [legendPosition, setLegendPosition] = useState( selectedWidget?.legendPosition || LegendPosition.BOTTOM, ); + const [customLegendColors, setCustomLegendColors] = useState< + Record + >(selectedWidget?.customLegendColors || {}); + const [saveModal, setSaveModal] = useState(false); const [discardModal, setDiscardModal] = useState(false); @@ -257,6 +264,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { selectedTracesFields, isLogScale, legendPosition, + customLegendColors, columnWidths: columnWidths?.[selectedWidget?.id], }; }); @@ -282,6 +290,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { stackedBarChart, isLogScale, legendPosition, + customLegendColors, columnWidths, ]); @@ -340,6 +349,11 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { // hence while changing the query contains the older value and the processing logic fails const [isLoadingPanelData, setIsLoadingPanelData] = useState(false); + // State to hold query response for sharing between left and right containers + const [queryResponse, setQueryResponse] = useState< + UseQueryResult, Error> + >(null as any); + // request data should be handled by the parent and the child components should consume the same // this has been moved here from the left container const [requestData, setRequestData] = useState(() => { @@ -482,6 +496,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { selectedLogFields: selectedWidget?.selectedLogFields || [], selectedTracesFields: selectedWidget?.selectedTracesFields || [], legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM, + customLegendColors: selectedWidget?.customLegendColors || {}, }, ] : [ @@ -510,6 +525,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { selectedLogFields: selectedWidget?.selectedLogFields || [], selectedTracesFields: selectedWidget?.selectedTracesFields || [], legendPosition: selectedWidget?.legendPosition || LegendPosition.BOTTOM, + customLegendColors: selectedWidget?.customLegendColors || {}, }, ...afterWidgets, ], @@ -723,6 +739,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { requestData={requestData} setRequestData={setRequestData} isLoadingPanelData={isLoadingPanelData} + setQueryResponse={setQueryResponse} /> )} @@ -766,6 +783,9 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { setIsLogScale={setIsLogScale} legendPosition={legendPosition} setLegendPosition={setLegendPosition} + customLegendColors={customLegendColors} + setCustomLegendColors={setCustomLegendColors} + queryResponse={queryResponse} softMin={softMin} setSoftMin={setSoftMin} softMax={softMax} diff --git a/frontend/src/container/NewWidget/types.ts b/frontend/src/container/NewWidget/types.ts index c3952e935a6f..0b9b001e7c20 100644 --- a/frontend/src/container/NewWidget/types.ts +++ b/frontend/src/container/NewWidget/types.ts @@ -27,6 +27,11 @@ export interface WidgetGraphProps { requestData: GetQueryResultsProps; setRequestData: Dispatch>; isLoadingPanelData: boolean; + setQueryResponse?: Dispatch< + SetStateAction< + UseQueryResult, Error> + > + >; } export type WidgetGraphContainerProps = { diff --git a/frontend/src/container/PanelWrapper/PiePanelWrapper.tsx b/frontend/src/container/PanelWrapper/PiePanelWrapper.tsx index 948f62af3f82..237331dcda5d 100644 --- a/frontend/src/container/PanelWrapper/PiePanelWrapper.tsx +++ b/frontend/src/container/PanelWrapper/PiePanelWrapper.tsx @@ -50,14 +50,19 @@ function PiePanelWrapper({ color: string; }[] = [].concat( ...(panelData - .map((d) => ({ - label: getLabelName(d.metric, d.queryName || '', d.legend || ''), - value: d.values?.[0]?.[1], - color: generateColor( - getLabelName(d.metric, d.queryName || '', d.legend || ''), - isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor, - ), - })) + .map((d) => { + const label = getLabelName(d.metric, d.queryName || '', d.legend || ''); + return { + label, + value: d.values?.[0]?.[1], + color: + widget?.customLegendColors?.[label] || + generateColor( + label, + isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor, + ), + }; + }) .filter((d) => d !== undefined) as never[]), ); diff --git a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx index 67099b48bd60..1f50bc9eb127 100644 --- a/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx +++ b/frontend/src/container/PanelWrapper/UplotPanelWrapper.tsx @@ -138,6 +138,7 @@ function UplotPanelWrapper({ timezone: timezone.value, customSeries, isLogScale: widget?.isLogScale, + colorMapping: widget?.customLegendColors, enhancedLegend: true, // Enable enhanced legend legendPosition: widget?.legendPosition, }), @@ -166,6 +167,7 @@ function UplotPanelWrapper({ customSeries, widget?.isLogScale, widget?.legendPosition, + widget?.customLegendColors, ], ); diff --git a/frontend/src/lib/uPlotLib/utils/getSeriesData.ts b/frontend/src/lib/uPlotLib/utils/getSeriesData.ts index 5de1f6d207c4..2c72acb6d6f1 100644 --- a/frontend/src/lib/uPlotLib/utils/getSeriesData.ts +++ b/frontend/src/lib/uPlotLib/utils/getSeriesData.ts @@ -34,6 +34,7 @@ const getSeries = ({ panelType, hiddenGraph, isDarkMode, + colorMapping, }: GetSeriesProps): uPlot.Options['series'] => { const configurations: uPlot.Series[] = [ { label: 'Timestamp', stroke: 'purple' }, @@ -52,10 +53,12 @@ const getSeries = ({ legend || '', ); - const color = generateColor( - label, - isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor, - ); + const color = + colorMapping?.[label] || + generateColor( + label, + isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor, + ); const pointSize = seriesList[i].values.length > 1 ? 5 : 10; const showPoints = !(seriesList[i].values.length > 1); @@ -105,6 +108,7 @@ export type GetSeriesProps = { hiddenGraph?: { [key: string]: boolean; }; + colorMapping?: Record; }; export default getSeries; diff --git a/frontend/src/types/api/dashboard/getAll.ts b/frontend/src/types/api/dashboard/getAll.ts index d9bfb1af43f0..2e6d88328711 100644 --- a/frontend/src/types/api/dashboard/getAll.ts +++ b/frontend/src/types/api/dashboard/getAll.ts @@ -117,6 +117,7 @@ export interface IBaseWidget { isLogScale?: boolean; columnWidths?: Record; legendPosition?: LegendPosition; + customLegendColors?: Record; } export interface Widgets extends IBaseWidget { query: Query; From b921a2280b01241d492b663c18902ad442a61a56 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Tue, 27 May 2025 16:32:25 +0530 Subject: [PATCH 17/24] Update pull_request_template.md (#8065) --- .github/pull_request_template.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6260fd594062..0691744c3223 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,9 +32,7 @@ ex: > Tag the relevant teams for review: -- [ ] @SigNoz/frontend -- [ ] @SigNoz/backend -- [ ] @SigNoz/devops +- frontend / backend / devops --- From 62810428d87826fa4064ed6466904e93d11798ac Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Tue, 27 May 2025 19:21:38 +0530 Subject: [PATCH 18/24] chore: add logs statement builder base (#8024) --- pkg/telemetrylogs/filter_compiler.go | 55 +++ pkg/telemetrylogs/statement_builder.go | 442 +++++++++++++++++++++++++ pkg/telemetrylogs/stmt_builder_test.go | 117 +++++++ 3 files changed, 614 insertions(+) create mode 100644 pkg/telemetrylogs/filter_compiler.go create mode 100644 pkg/telemetrylogs/statement_builder.go create mode 100644 pkg/telemetrylogs/stmt_builder_test.go diff --git a/pkg/telemetrylogs/filter_compiler.go b/pkg/telemetrylogs/filter_compiler.go new file mode 100644 index 000000000000..69dc90bd5297 --- /dev/null +++ b/pkg/telemetrylogs/filter_compiler.go @@ -0,0 +1,55 @@ +package telemetrylogs + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/querybuilder" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +type FilterCompilerOpts struct { + FieldMapper qbtypes.FieldMapper + ConditionBuilder qbtypes.ConditionBuilder + MetadataStore telemetrytypes.MetadataStore + FullTextColumn *telemetrytypes.TelemetryFieldKey + JsonBodyPrefix string + JsonKeyToKey qbtypes.JsonKeyToFieldFunc + SkipResourceFilter bool +} + +type filterCompiler struct { + opts FilterCompilerOpts +} + +func NewFilterCompiler(opts FilterCompilerOpts) *filterCompiler { + return &filterCompiler{ + opts: opts, + } +} + +func (c *filterCompiler) Compile(ctx context.Context, expr string) (*sqlbuilder.WhereClause, []string, error) { + selectors := querybuilder.QueryStringToKeysSelectors(expr) + + keys, err := c.opts.MetadataStore.GetKeysMulti(ctx, selectors) + if err != nil { + return nil, nil, err + } + + filterWhereClause, warnings, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{ + FieldMapper: c.opts.FieldMapper, + ConditionBuilder: c.opts.ConditionBuilder, + FieldKeys: keys, + FullTextColumn: c.opts.FullTextColumn, + JsonBodyPrefix: c.opts.JsonBodyPrefix, + JsonKeyToKey: c.opts.JsonKeyToKey, + SkipResourceFilter: c.opts.SkipResourceFilter, + }) + + if err != nil { + return nil, nil, err + } + + return filterWhereClause, warnings, nil +} diff --git a/pkg/telemetrylogs/statement_builder.go b/pkg/telemetrylogs/statement_builder.go new file mode 100644 index 000000000000..1806624a637e --- /dev/null +++ b/pkg/telemetrylogs/statement_builder.go @@ -0,0 +1,442 @@ +package telemetrylogs + +import ( + "context" + "fmt" + "strings" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/querybuilder" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" +) + +var ( + ErrUnsupportedAggregation = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported aggregation") +) + +type LogQueryStatementBuilderOpts struct { + MetadataStore telemetrytypes.MetadataStore + FieldMapper qbtypes.FieldMapper + ConditionBuilder qbtypes.ConditionBuilder + ResourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation] + Compiler qbtypes.FilterCompiler + AggExprRewriter qbtypes.AggExprRewriter +} + +type logQueryStatementBuilder struct { + opts LogQueryStatementBuilderOpts + fm qbtypes.FieldMapper + cb qbtypes.ConditionBuilder + compiler qbtypes.FilterCompiler + aggExprRewriter qbtypes.AggExprRewriter +} + +var _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*logQueryStatementBuilder)(nil) + +func NewLogQueryStatementBuilder(opts LogQueryStatementBuilderOpts) *logQueryStatementBuilder { + return &logQueryStatementBuilder{ + opts: opts, + fm: opts.FieldMapper, + cb: opts.ConditionBuilder, + compiler: opts.Compiler, + aggExprRewriter: opts.AggExprRewriter, + } +} + +// Build builds a SQL query for logs based on the given parameters +func (b *logQueryStatementBuilder) Build( + ctx context.Context, + start uint64, + end uint64, + requestType qbtypes.RequestType, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], +) (*qbtypes.Statement, error) { + + start = querybuilder.ToNanoSecs(start) + end = querybuilder.ToNanoSecs(end) + + keySelectors := getKeySelectors(query) + keys, err := b.opts.MetadataStore.GetKeysMulti(ctx, keySelectors) + if err != nil { + return nil, err + } + + // Create SQL builder + q := sqlbuilder.ClickHouse.NewSelectBuilder() + + switch requestType { + case qbtypes.RequestTypeRaw: + return b.buildListQuery(ctx, q, query, start, end, keys) + case qbtypes.RequestTypeTimeSeries: + return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys) + case qbtypes.RequestTypeScalar: + return b.buildScalarQuery(ctx, q, query, start, end, keys, false) + } + + return nil, fmt.Errorf("unsupported request type: %s", requestType) +} + +func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) []*telemetrytypes.FieldKeySelector { + var keySelectors []*telemetrytypes.FieldKeySelector + + for idx := range query.Aggregations { + aggExpr := query.Aggregations[idx] + selectors := querybuilder.QueryStringToKeysSelectors(aggExpr.Expression) + keySelectors = append(keySelectors, selectors...) + } + + whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) + keySelectors = append(keySelectors, whereClauseSelectors...) + + return keySelectors +} + +// buildListQuery builds a query for list panel type +func (b *logQueryStatementBuilder) buildListQuery( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], + start, end uint64, + _ map[string][]*telemetrytypes.TelemetryFieldKey, +) (*qbtypes.Statement, error) { + + var ( + cteFragments []string + cteArgs [][]any + ) + + if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end); err != nil { + return nil, err + } else if frag != "" { + cteFragments = append(cteFragments, frag) + cteArgs = append(cteArgs, args) + } + + // Select default columns + sb.Select( + "timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string", + ) + + // From table + sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName)) + + // Add filter conditions + warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + if err != nil { + return nil, err + } + + // Add order by + for _, orderBy := range query.Order { + sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction)) + } + + // Add limit and offset + if query.Limit > 0 { + sb.Limit(query.Limit) + } + + if query.Offset > 0 { + sb.Offset(query.Offset) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse) + + finalSQL := combineCTEs(cteFragments) + mainSQL + finalArgs := prependArgs(cteArgs, mainArgs) + + return &qbtypes.Statement{ + Query: finalSQL, + Args: finalArgs, + Warnings: warnings, + }, nil +} + +func (b *logQueryStatementBuilder) buildTimeSeriesQuery( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], + start, end uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, +) (*qbtypes.Statement, error) { + + var ( + cteFragments []string + cteArgs [][]any + ) + + if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end); err != nil { + return nil, err + } else if frag != "" { + cteFragments = append(cteFragments, frag) + cteArgs = append(cteArgs, args) + } + + sb.SelectMore(fmt.Sprintf( + "toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL %d SECOND) AS ts", + int64(query.StepInterval.Seconds()), + )) + + // Keep original column expressions so we can build the tuple + fieldNames := make([]string, 0, len(query.GroupBy)) + for _, gb := range query.GroupBy { + colExpr, err := b.fm.ColumnExpressionFor(ctx, &gb.TelemetryFieldKey, keys) + if err != nil { + return nil, err + } + sb.SelectMore(colExpr) + fieldNames = append(fieldNames, fmt.Sprintf("`%s`", gb.TelemetryFieldKey.Name)) + } + + // Aggregations + allAggChArgs := make([]any, 0) + for i, agg := range query.Aggregations { + rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, agg.Expression) + if err != nil { + return nil, err + } + allAggChArgs = append(allAggChArgs, chArgs...) + sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i)) + } + + sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName)) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + if err != nil { + return nil, err + } + + var finalSQL string + var finalArgs []any + + if query.Limit > 0 { + // build the scalar “top/bottom-N” query in its own builder. + cteSB := sqlbuilder.ClickHouse.NewSelectBuilder() + cteStmt, err := b.buildScalarQuery(ctx, cteSB, query, start, end, keys, true) + if err != nil { + return nil, err + } + + cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", cteStmt.Query)) + cteArgs = append(cteArgs, cteStmt.Args) + + // Constrain the main query to the rows that appear in the CTE. + tuple := fmt.Sprintf("(%s)", strings.Join(fieldNames, ", ")) + sb.Where(fmt.Sprintf("%s IN (SELECT %s FROM __limit_cte)", tuple, strings.Join(fieldNames, ", "))) + + // Group by all dimensions + sb.GroupBy("ALL") + if query.Having != nil && query.Having.Expression != "" { + sb.Having(query.Having.Expression) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + + // Stitch it all together: WITH … SELECT … + finalSQL = combineCTEs(cteFragments) + mainSQL + finalArgs = prependArgs(cteArgs, mainArgs) + + } else { + sb.GroupBy("ALL") + if query.Having != nil && query.Having.Expression != "" { + sb.Having(query.Having.Expression) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + + // Stitch it all together: WITH … SELECT … + finalSQL = combineCTEs(cteFragments) + mainSQL + finalArgs = prependArgs(cteArgs, mainArgs) + } + + return &qbtypes.Statement{ + Query: finalSQL, + Args: finalArgs, + Warnings: warnings, + }, nil +} + +// buildScalarQuery builds a query for scalar panel type +func (b *logQueryStatementBuilder) buildScalarQuery( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], + start, end uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, + skipResourceCTE bool, +) (*qbtypes.Statement, error) { + + var ( + cteFragments []string + cteArgs [][]any + ) + + if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end); err != nil { + return nil, err + } else if frag != "" && !skipResourceCTE { + cteFragments = append(cteFragments, frag) + cteArgs = append(cteArgs, args) + } + + allAggChArgs := []any{} + + // Add group by columns + for _, groupBy := range query.GroupBy { + colExpr, err := b.fm.ColumnExpressionFor(ctx, &groupBy.TelemetryFieldKey, keys) + if err != nil { + return nil, err + } + sb.SelectMore(colExpr) + } + + // Add aggregation + if len(query.Aggregations) > 0 { + for idx := range query.Aggregations { + aggExpr := query.Aggregations[idx] + rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, aggExpr.Expression) + if err != nil { + return nil, err + } + allAggChArgs = append(allAggChArgs, chArgs...) + sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx)) + } + } + + // From table + sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName)) + + // Add filter conditions + warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + if err != nil { + return nil, err + } + + // Group by dimensions + sb.GroupBy("ALL") + + // Add having clause if needed + if query.Having != nil && query.Having.Expression != "" { + sb.Having(query.Having.Expression) + } + + // Add order by + for _, orderBy := range query.Order { + idx, ok := aggOrderBy(orderBy, query) + if ok { + sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction)) + } else { + sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction)) + } + } + + // if there is no order by, then use the __result_0 as the order by + if len(query.Order) == 0 { + sb.OrderBy("__result_0 DESC") + } + + // Add limit and offset + if query.Limit > 0 { + sb.Limit(query.Limit) + } + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + + finalSQL := combineCTEs(cteFragments) + mainSQL + finalArgs := prependArgs(cteArgs, mainArgs) + + return &qbtypes.Statement{ + Query: finalSQL, + Args: finalArgs, + Warnings: warnings, + }, nil +} + +// buildFilterCondition builds SQL condition from filter expression +func (b *logQueryStatementBuilder) addFilterCondition(ctx context.Context, sb *sqlbuilder.SelectBuilder, start, end uint64, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) ([]string, error) { + + // add filter expression + + filterWhereClause, warnings, err := b.compiler.Compile(ctx, query.Filter.Expression) + + if err != nil { + return nil, err + } + + if filterWhereClause != nil { + sb.AddWhereClause(filterWhereClause) + } + + // add time filter + startBucket := start/1000000000 - 1800 + endBucket := end / 1000000000 + + sb.Where(sb.GE("timestamp", start), sb.LE("timestamp", end), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket)) + + return warnings, nil +} + +// combineCTEs takes any number of individual CTE fragments like +// +// "__resource_filter AS (...)", "__limit_cte AS (...)" +// +// and renders the final `WITH …` clause. +func combineCTEs(ctes []string) string { + if len(ctes) == 0 { + return "" + } + return "WITH " + strings.Join(ctes, ", ") + " " +} + +// prependArgs ensures CTE arguments appear before main-query arguments +// in the final slice so their ordinal positions match the SQL string. +func prependArgs(cteArgs [][]any, mainArgs []any) []any { + out := make([]any, 0, len(mainArgs)+len(cteArgs)) + for _, a := range cteArgs { // CTEs first, in declaration order + out = append(out, a...) + } + return append(out, mainArgs...) +} + +func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) (int, bool) { + for i, agg := range q.Aggregations { + if k.Key.Name == agg.Alias || + k.Key.Name == agg.Expression || + k.Key.Name == fmt.Sprintf("%d", i) { + return i, true + } + } + return 0, false +} + +func (b *logQueryStatementBuilder) maybeAttachResourceFilter( + ctx context.Context, + sb *sqlbuilder.SelectBuilder, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], + start, end uint64, +) (cteSQL string, cteArgs []any, err error) { + + stmt, err := b.buildResourceFilterCTE(ctx, query, start, end) + if err != nil { + return "", nil, err + } + + sb.Where("resource_fingerprint IN (SELECT fingerprint FROM __resource_filter)") + + return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil +} + +func (b *logQueryStatementBuilder) buildResourceFilterCTE( + ctx context.Context, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], + start, end uint64, +) (*qbtypes.Statement, error) { + + return b.opts.ResourceFilterStmtBuilder.Build( + ctx, + start, + end, + qbtypes.RequestTypeRaw, + query, + ) +} diff --git a/pkg/telemetrylogs/stmt_builder_test.go b/pkg/telemetrylogs/stmt_builder_test.go new file mode 100644 index 000000000000..418b2ce52917 --- /dev/null +++ b/pkg/telemetrylogs/stmt_builder_test.go @@ -0,0 +1,117 @@ +package telemetrylogs + +import ( + "context" + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/querybuilder" + "github.com/SigNoz/signoz/pkg/querybuilder/resourcefilter" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest" + "github.com/stretchr/testify/require" +) + +func resourceFilterStmtBuilder() (qbtypes.StatementBuilder[qbtypes.LogAggregation], error) { + fm := resourcefilter.NewFieldMapper() + cb := resourcefilter.NewConditionBuilder(fm) + mockMetadataStore := telemetrytypestest.NewMockMetadataStore() + mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() + compiler := resourcefilter.NewFilterCompiler(resourcefilter.FilterCompilerOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + MetadataStore: mockMetadataStore, + }) + + return resourcefilter.NewLogResourceFilterStatementBuilder(resourcefilter.ResourceFilterStatementBuilderOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + Compiler: compiler, + }), nil +} + +func TestStatementBuilder(t *testing.T) { + cases := []struct { + name string + requestType qbtypes.RequestType + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation] + expected qbtypes.Statement + expectedErr error + }{ + { + name: "test", + requestType: qbtypes.RequestTypeTimeSeries, + query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{ + Signal: telemetrytypes.SignalLogs, + StepInterval: qbtypes.Step{Duration: 30 * time.Second}, + Aggregations: []qbtypes.LogAggregation{ + { + Expression: "count()", + }, + }, + Filter: &qbtypes.Filter{ + Expression: "service.name = 'cartservice'", + }, + Limit: 10, + GroupBy: []qbtypes.GroupByKey{ + { + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{ + Name: "service.name", + }, + }, + }, + }, + expected: qbtypes.Statement{ + Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ALL ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) IN (SELECT `service.name` FROM __limit_cte) GROUP BY ALL", + Args: []any{"cartservice", "%service.name%", "%service.name%cartservice%", uint64(1747945619), uint64(1747983448), uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448), 10, uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448)}, + }, + expectedErr: nil, + }, + } + + fm := NewFieldMapper() + cb := NewConditionBuilder(fm) + mockMetadataStore := telemetrytypestest.NewMockMetadataStore() + mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() + compiler := NewFilterCompiler(FilterCompilerOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + MetadataStore: mockMetadataStore, + SkipResourceFilter: true, + }) + aggExprRewriter := querybuilder.NewAggExprRewriter(querybuilder.AggExprRewriterOptions{ + FieldMapper: fm, + ConditionBuilder: cb, + MetadataStore: mockMetadataStore, + }) + + resourceFilterStmtBuilder, err := resourceFilterStmtBuilder() + require.NoError(t, err) + + statementBuilder := NewLogQueryStatementBuilder(LogQueryStatementBuilderOpts{ + FieldMapper: fm, + ConditionBuilder: cb, + Compiler: compiler, + MetadataStore: mockMetadataStore, + AggExprRewriter: aggExprRewriter, + ResourceFilterStmtBuilder: resourceFilterStmtBuilder, + }) + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + + q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query) + + if c.expectedErr != nil { + require.Error(t, err) + require.Contains(t, err.Error(), c.expectedErr.Error()) + } else { + require.NoError(t, err) + require.Equal(t, c.expected.Query, q.Query) + require.Equal(t, c.expected.Args, q.Args) + require.Equal(t, c.expected.Warnings, q.Warnings) + } + }) + } +} From 69e94cbd38c2b6a6fb20021a523bdb1e7ca6c5d7 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 27 May 2025 20:04:57 +0530 Subject: [PATCH 19/24] Custom Quick FIlters: Integration across other tabs (#8001) * chore: added filters init * chore: handle save and discard * chore: search and api intergrations * feat: search on filters * feat: style fix * feat: style fix * feat: signal to data source config * feat: search styles * feat: update drawer slide style * chore: quick filters - added filters init (#7867) Co-authored-by: Aditya Singh * feat: no results state * fix: minor fix * feat: qf setting ui * feat: add skeleton to dynamic qf * fix: minor fix * feat: announcement tooltip added * feat: announcement tooltip added refactor * feat: announcement tooltip styles * feat: announcement tooltip integration * fix: number vals in filter list * feat: announcement tooltip show logic added * feat: light mode styles * feat: remove unwanted styles * feat: remove filter disable when one filter added * style: minor style * fix: minor refactor * test: added test cases * feat: integrate custom quick filters in logs * Custom quick filter: Other Filters | Search integration (#7939) * chore: added filters init * chore: handle save and discard * chore: search and api intergrations * feat: search on filters * feat: style fix * feat: style fix * feat: signal to data source config * feat: search styles * feat: update drawer slide style * feat: no results state * fix: minor fix * Custom Quick FIlters: UI fixes and Announcement Tooltip (#7950) * feat: qf setting ui * feat: add skeleton to dynamic qf * fix: minor fix * feat: announcement tooltip added * feat: announcement tooltip added refactor * feat: announcement tooltip styles * feat: announcement tooltip integration * fix: number vals in filter list * feat: announcement tooltip show logic added * feat: light mode styles * feat: remove unwanted styles * feat: remove filter disable when one filter added * style: minor style --------- Co-authored-by: Aditya Singh --------- Co-authored-by: Aditya Singh * feat: code refactor * feat: debounce search * feat: refactor * feat: exceptions integrate * feat: api monitoring qf integrate * feat: handle query name show * Custom quick filters: Tests and pr review comments (#7967) * chore: added filters init * chore: handle save and discard * chore: search and api intergrations * feat: search on filters * feat: style fix * feat: style fix * feat: signal to data source config * feat: search styles * feat: update drawer slide style * feat: no results state * fix: minor fix * feat: qf setting ui * feat: add skeleton to dynamic qf * fix: minor fix * feat: announcement tooltip added * feat: announcement tooltip added refactor * feat: announcement tooltip styles * feat: announcement tooltip integration * fix: number vals in filter list * feat: announcement tooltip show logic added * feat: light mode styles * feat: remove unwanted styles * feat: remove filter disable when one filter added * style: minor style * fix: minor refactor * test: added test cases * feat: integrate custom quick filters in logs * feat: code refactor * feat: debounce search * feat: refactor --------- Co-authored-by: Aditya Singh * feat: integrate traces data source to settings * feat: duration nano traces filter in qf * fix: allow only admins to change qf settings * feat: has error handling * feat: fix existing tests * feat: update test cases * feat: update test cases * feat: minor refactor * feat: minor refactor * feat: log quick filter settings changes * feat: log quick filter settings changes * feat: log quick filter settings changes --------- Co-authored-by: Aditya Singh --- .../Checkbox/Checkbox.styles.scss | 24 +- .../FilterRenderers/Checkbox/Checkbox.tsx | 3 +- .../Duration/Duration.styles.scss | 174 +++++++++++ .../FilterRenderers/Duration/Duration.tsx | 281 ++++++++++++++++++ .../components/QuickFilters/QuickFilters.tsx | 193 +++++++----- .../QuickFiltersSettings/OtherFilters.tsx | 45 ++- .../QuickFiltersSettings.styles.scss | 10 + .../hooks/useQuickFilterSettings.tsx | 4 + .../QuickFilters/hooks/useFilterConfig.tsx | 10 +- .../QuickFilters/tests/QuickFilters.test.tsx | 77 ++++- frontend/src/components/QuickFilters/types.ts | 3 + .../src/components/QuickFilters/utils.tsx | 35 ++- .../ApiMonitoring/Explorer/Explorer.tsx | 34 +-- .../__mockdata__/customQuickFilters.ts | 7 + frontend/src/pages/AllErrors/index.tsx | 6 +- .../__test__/TracesExplorer.test.tsx | 113 +++---- frontend/src/pages/TracesExplorer/index.tsx | 12 +- 17 files changed, 825 insertions(+), 206 deletions(-) create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Duration/Duration.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Duration/Duration.tsx diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss index 73849b010a48..6524f55b1662 100644 --- a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss @@ -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); +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx index a9d96adf324f..784c7cdd2487 100644 --- a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -504,6 +504,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { onChange(value, currentFilterState[value], true); }} > +
{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)} )} + )} +
+ ); +} + +Duration.defaultProps = { + onFilterChange: (): void => {}, +}; + +export default Duration; diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx index ed443e8e4f67..af989aea13c9 100644 --- a/frontend/src/components/QuickFilters/QuickFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -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,36 +106,33 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { }; const lastQueryName = + showQueryName && currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName; return (
- {source !== QuickFiltersSource.INFRA_MONITORING && - source !== QuickFiltersSource.API_MONITORING && ( -
-
- - - {lastQueryName ? 'Filters for' : 'Filters'} - - {lastQueryName && ( - - - {lastQueryName} - - - )} -
- -
- -
- -
+ {source !== QuickFiltersSource.INFRA_MONITORING && ( +
+
+ + + {lastQueryName ? 'Filters for' : 'Filters'} + + {lastQueryName && ( + + {lastQueryName} + )} +
+ +
+ +
+ +
+
+ {showFilterCollapse && (
- {isDynamicFilters && ( - -
- setIsSettingsOpen(true)} - /> - { - setLocalStorageKey( - LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, - 'false', - ); - }} - /> -
-
- )} -
+ )} + {isDynamicFilters && isAdmin && ( + +
+ setIsSettingsOpen(true)} + /> + { + setLocalStorageKey( + LOCALSTORAGE.QUICK_FILTERS_SETTINGS_ANNOUNCEMENT, + 'false', + ); + }} + /> +
+
+ )}
- )} +
+ )} {isCustomFiltersLoading ? (
@@ -179,31 +188,51 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
) : ( -
- {filterConfig.map((filter) => { - switch (filter.type) { - case FiltersType.CHECKBOX: - return ( - - ); - case FiltersType.SLIDER: - return ; - // eslint-disable-next-line sonarjs/no-duplicated-branches - default: - return ( - - ); - } - })} -
+ <> + {source === QuickFiltersSource.API_MONITORING && ( +
+ Show IP addresses + { + logEvent('API Monitoring: Show IP addresses clicked', { + showIP: !(showIP ?? true), + }); + setParams({ showIP }); + }} + /> +
+ )} +
+ {filterConfig.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ( + + ); + case FiltersType.DURATION: + return ; + case FiltersType.SLIDER: + return ; + // eslint-disable-next-line sonarjs/no-duplicated-branches + default: + return ( + + ); + } + })} +
+
)}
@@ -235,4 +264,6 @@ QuickFilters.defaultProps = { onFilterChange: null, signal: '', config: [], + showFilterCollapse: true, + showQueryName: true, }; diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx index 05c9954295e3..635338ef5e78 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/OtherFilters.tsx @@ -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>; }): 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 ; + const isLoading = isFetchingSuggestions || isFetchingAggregateKeys; + if (isLoading) return ; if (!otherFilters?.length) return
No values found
; diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.styles.scss b/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.styles.scss index 6fa705434427..e6cbfc642e8c 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.styles.scss +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/QuickFiltersSettings.styles.scss @@ -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); diff --git a/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx b/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx index f365ddba0663..bf4406c3045a 100644 --- a/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx +++ b/frontend/src/components/QuickFilters/QuickFiltersSettings/hooks/useQuickFilterSettings.tsx @@ -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', diff --git a/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx b/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx index 2a095d6685ea..fb2659a6817b 100644 --- a/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx +++ b/frontend/src/components/QuickFilters/hooks/useFilterConfig.tsx @@ -33,7 +33,7 @@ const useFilterConfig = ({ const isDynamicFilters = useMemo(() => customFilters.length > 0, [ customFilters, ]); - const { isLoading: isCustomFiltersLoading } = useQuery< + const { isFetching: isCustomFiltersLoading } = useQuery< SuccessResponse | 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, diff --git a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx index 069d52d6ebe0..f998e587eeb0 100644 --- a/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx +++ b/frontend/src/components/QuickFilters/tests/QuickFilters.test.tsx @@ -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(); + 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 + }); }); diff --git a/frontend/src/components/QuickFilters/types.ts b/frontend/src/components/QuickFilters/types.ts index e39daf232d48..45c671e77e39 100644 --- a/frontend/src/components/QuickFilters/types.ts +++ b/frontend/src/components/QuickFilters/types.ts @@ -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 { diff --git a/frontend/src/components/QuickFilters/utils.tsx b/frontend/src/components/QuickFilters/utils.tsx index 4d1ed5ffa52f..4df900e8354d 100644 --- a/frontend/src/components/QuickFilters/utils.tsx +++ b/frontend/src/components/QuickFilters/utils.tsx @@ -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 = { + duration_nano: 'Duration', + hasError: 'Has Error (Status)', +}; + +const FILTER_TYPE_MAP: Record = { + 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), ); }; diff --git a/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx b/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx index 14c57a67426a..c255cab7c02b 100644 --- a/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx +++ b/frontend/src/container/ApiMonitoring/Explorer/Explorer.tsx @@ -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 { }>
-
- - Filters -
- -
- Show IP addresses - { - logEvent('API Monitoring: Show IP addresses clicked', { - showIP: !(showIP ?? true), - }); - setParams({ showIP }); - }} - /> -
- {}} />
diff --git a/frontend/src/mocks-server/__mockdata__/customQuickFilters.ts b/frontend/src/mocks-server/__mockdata__/customQuickFilters.ts index 3bc5a15e5351..bcb69e0db705 100644 --- a/frontend/src/mocks-server/__mockdata__/customQuickFilters.ts +++ b/frontend/src/mocks-server/__mockdata__/customQuickFilters.ts @@ -17,6 +17,13 @@ export const quickFiltersListResponse = { isColumn: false, isJSON: false, }, + { + key: 'duration_nano', + dataType: 'float64', + type: 'tag', + isColumn: false, + isJSON: false, + }, { key: 'quantity', dataType: 'float64', diff --git a/frontend/src/pages/AllErrors/index.tsx b/frontend/src/pages/AllErrors/index.tsx index 38d7bc44b336..d2f048c4a66e 100644 --- a/frontend/src/pages/AllErrors/index.tsx +++ b/frontend/src/pages/AllErrors/index.tsx @@ -6,7 +6,7 @@ import getLocalStorageKey from 'api/browser/localstorage/get'; import setLocalStorageApi from 'api/browser/localstorage/set'; import cx from 'classnames'; import QuickFilters from 'components/QuickFilters/QuickFilters'; -import { QuickFiltersSource } from 'components/QuickFilters/types'; +import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types'; import RouteTab from 'components/RouteTab'; import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -20,7 +20,6 @@ import { useState } from 'react'; import { useLocation } from 'react-router-dom'; import { routes } from './config'; -import { ExceptionsQuickFiltersConfig } from './utils'; function AllErrors(): JSX.Element { const { pathname } = useLocation(); @@ -49,8 +48,9 @@ function AllErrors(): JSX.Element { {showFilters && (
diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx index 91e3bd1c21c8..acce20df6a7a 100644 --- a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -1,5 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ import userEvent from '@testing-library/user-event'; +import { ENVIRONMENT } from 'constants/env'; import { initialQueriesMap, initialQueryBuilderFormValues, @@ -7,10 +8,10 @@ import { } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; import * as compositeQueryHook from 'hooks/queryBuilder/useGetCompositeQueryParam'; +import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters'; import { queryRangeForListView, queryRangeForTableView, - queryRangeForTimeSeries, queryRangeForTraceView, } from 'mocks-server/__mockdata__/query_range'; import { server } from 'mocks-server/server'; @@ -18,6 +19,7 @@ import { rest } from 'msw'; import { QueryBuilderContext } from 'providers/QueryBuilder'; import { act, + cleanup, fireEvent, render, screen, @@ -42,6 +44,9 @@ import { const historyPush = jest.fn(); +const BASE_URL = ENVIRONMENT.baseURL; +const FILTER_SERVICE_NAME = 'Service Name'; + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: (): { pathname: string } => ({ @@ -435,24 +440,6 @@ describe('TracesExplorer - Filters', () => { ][0].builder.queryData[0].filters.items, ).toEqual([]); }); - - it('filter panel should collapse & uncollapsed', async () => { - const { getByText, getByTestId } = render(); - - Object.values(AllTraceFilterKeyValue).forEach((filter) => { - expect(getByText(filter)).toBeInTheDocument(); - }); - - // Filter panel should collapse - const collapseButton = getByTestId('toggle-filter-panel'); - expect(collapseButton).toBeInTheDocument(); - fireEvent.click(collapseButton); - - // uncollapse btn should be present - expect( - await screen.findByTestId('filter-uncollapse-btn'), - ).toBeInTheDocument(); - }); }); const handleExplorerTabChangeTest = jest.fn(); @@ -463,57 +450,32 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({ })); describe('TracesExplorer - ', () => { - it('should render the traces explorer page', async () => { + const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`; + + const setupServer = (): void => { server.use( - rest.post('http://localhost/api/v4/query_range', (req, res, ctx) => - res(ctx.status(200), ctx.json(queryRangeForTimeSeries)), + rest.get(quickFiltersListURL, (_, res, ctx) => + res(ctx.status(200), ctx.json(quickFiltersListResponse)), ), ); - const { findByText, getByText } = render(); + }; - // assert mocked date time selection - expect(await findByText('MockDateTimeSelection')).toBeInTheDocument(); - - // assert stage&Btn - expect(getByText('Stage & Run Query')).toBeInTheDocument(); - - // assert QB - will not write tests for QB as that would be covererd in QB tests separately - expect( - getByText( - 'Search Filter : select options from suggested values, for IN/NOT IN operators - press "Enter" after selecting options', - ), - ).toBeInTheDocument(); - expect(getByText('AGGREGATION INTERVAL')).toBeInTheDocument(); - // why is this present here?? - // expect(getByText('Metrics name')).toBeInTheDocument(); - // expect(getByText('WHERE')).toBeInTheDocument(); - // expect(getByText('Legend Format')).toBeInTheDocument(); - - // assert timeseries chart mock - // expect(await screen.findByText('MockUplot')).toBeInTheDocument(); + beforeEach(() => { + setupServer(); }); - it('check tab navigation', async () => { - const { getByTestId, getByText } = render(); + afterEach(() => { + server.resetHandlers(); + }); - // switch to Table view - const TableBtn = getByText('Table View'); - expect(TableBtn).toBeInTheDocument(); - fireEvent.click(TableBtn); - - expect(handleExplorerTabChangeTest).toBeCalledWith(PANEL_TYPES.TABLE); - - // switch to traces view - const tracesBtn = getByTestId('Traces'); - expect(tracesBtn).toBeInTheDocument(); - fireEvent.click(tracesBtn); - - expect(handleExplorerTabChangeTest).toBeCalledWith(PANEL_TYPES.TRACE); + afterAll(() => { + server.close(); + cleanup(); }); it('trace explorer - list view', async () => { server.use( - rest.post('http://localhost/api/v4/query_range', (req, res, ctx) => + rest.post(`${BASE_URL}/api/v4/query_range`, (req, res, ctx) => res(ctx.status(200), ctx.json(queryRangeForListView)), ), ); @@ -524,6 +486,7 @@ describe('TracesExplorer - ', () => { , ); + await screen.findByText(FILTER_SERVICE_NAME); expect(await screen.findByText('Timestamp')).toBeInTheDocument(); expect(getByText('options_menu.options')).toBeInTheDocument(); @@ -536,7 +499,7 @@ describe('TracesExplorer - ', () => { it('trace explorer - table view', async () => { server.use( - rest.post('http://localhost/api/v4/query_range', (req, res, ctx) => + rest.post(`${BASE_URL}/api/v4/query_range`, (req, res, ctx) => res(ctx.status(200), ctx.json(queryRangeForTableView)), ), ); @@ -554,7 +517,7 @@ describe('TracesExplorer - ', () => { it('trace explorer - trace view', async () => { server.use( - rest.post('http://localhost/api/v4/query_range', (req, res, ctx) => + rest.post(`${BASE_URL}/api/v4/query_range`, (req, res, ctx) => res(ctx.status(200), ctx.json(queryRangeForTraceView)), ), ); @@ -591,7 +554,11 @@ describe('TracesExplorer - ', () => { }); it('test for explorer options', async () => { - const { getByText, getByTestId } = render(); + const { getByText, getByTestId } = render( + + + , + ); // assert explorer options - action btns [ @@ -619,8 +586,12 @@ describe('TracesExplorer - ', () => { }); it('select a view options - assert and save this view', async () => { - const { container } = render(); - + const { container } = render( + + + , + ); + await screen.findByText(FILTER_SERVICE_NAME); await act(async () => { fireEvent.mouseDown( container.querySelector( @@ -664,7 +635,12 @@ describe('TracesExplorer - ', () => { }); it('create a dashboard btn assert', async () => { - const { getByText } = render(); + const { getByText } = render( + + + , + ); + await screen.findByText(FILTER_SERVICE_NAME); const createDashboardBtn = getByText('Add to Dashboard'); expect(createDashboardBtn).toBeInTheDocument(); @@ -687,7 +663,12 @@ describe('TracesExplorer - ', () => { }); it('create an alert btn assert', async () => { - const { getByText } = render(); + const { getByText } = render( + + + , + ); + await screen.findByText(FILTER_SERVICE_NAME); const createAlertBtn = getByText('Create an Alert'); expect(createAlertBtn).toBeInTheDocument(); diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index 448e41bf337c..c1e82a82d0cb 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -7,6 +7,8 @@ import logEvent from 'api/common/logEvent'; import axios from 'axios'; import cx from 'classnames'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types'; import { LOCALSTORAGE } from 'constants/localStorage'; import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; @@ -34,7 +36,6 @@ import { DataSource } from 'types/common/queryBuilder'; import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink'; import { v4 } from 'uuid'; -import { Filter } from './Filter/Filter'; import { ActionsWrapper, Container } from './styles'; import { getTabsItems } from './utils'; @@ -244,7 +245,14 @@ function TracesExplorer(): JSX.Element { }>
Date: Tue, 27 May 2025 20:29:09 +0530 Subject: [PATCH 20/24] feat: oss - sso and api keys (#8068) * feat: oss - sso and api keys * feat: show to community and community enterprise --------- Co-authored-by: Vikrant Gupta --- frontend/src/container/Home/Home.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/container/Home/Home.tsx b/frontend/src/container/Home/Home.tsx index 2150faee7b8f..56257021e5ac 100644 --- a/frontend/src/container/Home/Home.tsx +++ b/frontend/src/container/Home/Home.tsx @@ -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( defaultChecklistItemsState, ); @@ -323,22 +326,27 @@ export default function Home(): JSX.Element { setIsBannerDismissed(true); }; + const showBanner = useMemo( + () => !isBannerDismissed && (isCommunityUser || isCommunityEnterpriseUser), + [isBannerDismissed, isCommunityUser, isCommunityEnterpriseUser], + ); + return (
- {!isBannerDismissed && ( + {showBanner && (
From 0ec1be1ddfc02371607d46927037e089061b7a1b Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Tue, 27 May 2025 20:54:48 +0530 Subject: [PATCH 21/24] chore: add querier base implementation (#8028) --- pkg/querier/builder_query.go | 204 ++++++++++ pkg/querier/clickhouse_query.go | 77 ++++ pkg/querier/consume.go | 373 ++++++++++++++++++ pkg/querier/list_range.go | 36 ++ pkg/querier/promql_query.go | 41 ++ pkg/querier/querier.go | 96 +++++ pkg/querybuilder/agg_funcs.go | 65 +-- pkg/querybuilder/agg_rewrite.go | 95 ++--- pkg/querybuilder/cte.go | 27 ++ pkg/querybuilder/fallback_expr.go | 96 +++++ .../resourcefilter/filter_compiler.go | 47 --- .../resourcefilter/statement_builder.go | 84 ++-- pkg/querybuilder/time.go | 5 + pkg/telemetrylogs/condition_builder.go | 5 + pkg/telemetrylogs/condition_builder_test.go | 9 +- pkg/telemetrylogs/statement_builder.go | 188 +++++---- pkg/telemetrylogs/stmt_builder_test.go | 57 ++- pkg/telemetrylogs/test_data.go | 9 +- pkg/telemetrymetadata/metadata.go | 17 +- pkg/telemetrytraces/condition_builder.go | 5 + pkg/telemetrytraces/field_mapper.go | 2 +- pkg/telemetrytraces/filter_compiler.go | 55 --- pkg/telemetrytraces/statement_builder.go | 214 ++++++---- pkg/telemetrytraces/stmt_builder_test.go | 48 +-- pkg/telemetrytraces/test_data.go | 8 +- .../querybuildertypesv5/qb.go | 18 +- .../querybuildertypesv5/query.go | 15 +- .../querybuildertypesv5/resp.go | 31 +- 28 files changed, 1452 insertions(+), 475 deletions(-) create mode 100644 pkg/querier/builder_query.go create mode 100644 pkg/querier/clickhouse_query.go create mode 100644 pkg/querier/consume.go create mode 100644 pkg/querier/list_range.go create mode 100644 pkg/querier/promql_query.go create mode 100644 pkg/querier/querier.go create mode 100644 pkg/querybuilder/cte.go create mode 100644 pkg/querybuilder/fallback_expr.go delete mode 100644 pkg/querybuilder/resourcefilter/filter_compiler.go delete mode 100644 pkg/telemetrytraces/filter_compiler.go diff --git a/pkg/querier/builder_query.go b/pkg/querier/builder_query.go new file mode 100644 index 000000000000..dfb041dd655f --- /dev/null +++ b/pkg/querier/builder_query.go @@ -0,0 +1,204 @@ +package querier + +import ( + "context" + "encoding/base64" + "strconv" + "strings" + "time" + + "github.com/SigNoz/signoz/pkg/telemetrystore" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" +) + +type builderQuery[T any] struct { + telemetryStore telemetrystore.TelemetryStore + stmtBuilder qbtypes.StatementBuilder[T] + spec qbtypes.QueryBuilderQuery[T] + + fromMS uint64 + toMS uint64 + kind qbtypes.RequestType +} + +var _ qbtypes.Query = (*builderQuery[any])(nil) + +func newBuilderQuery[T any]( + telemetryStore telemetrystore.TelemetryStore, + stmtBuilder qbtypes.StatementBuilder[T], + spec qbtypes.QueryBuilderQuery[T], + tr qbtypes.TimeRange, + kind qbtypes.RequestType, +) *builderQuery[T] { + return &builderQuery[T]{ + telemetryStore: telemetryStore, + stmtBuilder: stmtBuilder, + spec: spec, + fromMS: tr.From, + toMS: tr.To, + kind: kind, + } +} + +func (q *builderQuery[T]) Fingerprint() string { + // TODO: implement this + return "" +} + +func (q *builderQuery[T]) Window() (uint64, uint64) { + return q.fromMS, q.toMS +} + +// must be a single query, ordered by timestamp (logs need an id tie-break). +func (q *builderQuery[T]) isWindowList() bool { + if len(q.spec.Order) == 0 { + return false + } + + // first ORDER BY must be `timestamp` + if q.spec.Order[0].Key.Name != "timestamp" { + return false + } + + if q.spec.Signal == telemetrytypes.SignalLogs { + // logs require timestamp,id with identical direction + if len(q.spec.Order) != 2 || q.spec.Order[1].Key.Name != "id" || + q.spec.Order[1].Direction != q.spec.Order[0].Direction { + return false + } + } + return true +} + +func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) { + + // can we do window based pagination? + if q.kind == qbtypes.RequestTypeRaw && q.isWindowList() { + return q.executeWindowList(ctx) + } + + stmt, err := q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec) + if err != nil { + return nil, err + } + + chQuery := qbtypes.ClickHouseQuery{ + Name: q.spec.Name, + Query: stmt.Query, + } + + chExec := newchSQLQuery(q.telemetryStore, chQuery, stmt.Args, qbtypes.TimeRange{From: q.fromMS, To: q.toMS}, q.kind) + result, err := chExec.Execute(ctx) + if err != nil { + return nil, err + } + result.Warnings = stmt.Warnings + return result, nil +} + +func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Result, error) { + isAsc := len(q.spec.Order) > 0 && + strings.ToLower(string(q.spec.Order[0].Direction.StringValue())) == "asc" + + // Adjust [fromMS,toMS] window if a cursor was supplied + if cur := strings.TrimSpace(q.spec.Cursor); cur != "" { + if ts, err := decodeCursor(cur); err == nil { + if isAsc { + if uint64(ts) >= q.fromMS { + q.fromMS = uint64(ts + 1) + } + } else { // DESC + if uint64(ts) <= q.toMS { + q.toMS = uint64(ts - 1) + } + } + } + } + + reqLimit := q.spec.Limit + if reqLimit == 0 { + reqLimit = 10_000 // sane upper-bound default + } + offsetLeft := q.spec.Offset + need := reqLimit + offsetLeft // rows to fetch from ClickHouse + + var rows []*qbtypes.RawRow + + totalRows := uint64(0) + totalBytes := uint64(0) + start := time.Now() + + for _, r := range makeBuckets(q.fromMS, q.toMS) { + q.spec.Offset = 0 + q.spec.Limit = need + + stmt, err := q.stmtBuilder.Build(ctx, r.fromNS/1e6, r.toNS/1e6, q.kind, q.spec) + if err != nil { + return nil, err + } + + chExec := newchSQLQuery( + q.telemetryStore, + qbtypes.ClickHouseQuery{Name: q.spec.Name, Query: stmt.Query}, + stmt.Args, + qbtypes.TimeRange{From: q.fromMS, To: q.toMS}, + q.kind, + ) + res, err := chExec.Execute(ctx) + if err != nil { + return nil, err + } + totalRows += res.Stats.RowsScanned + totalBytes += res.Stats.BytesScanned + + rawRows := res.Value.(*qbtypes.RawData).Rows + need -= len(rawRows) + + for _, rr := range rawRows { + if offsetLeft > 0 { // client-requested initial offset + offsetLeft-- + continue + } + rows = append(rows, rr) + if len(rows) >= reqLimit { // page filled + break + } + } + if len(rows) >= reqLimit { + break + } + } + + nextCursor := "" + if len(rows) == reqLimit { + lastTS := rows[len(rows)-1].Timestamp.UnixMilli() + nextCursor = encodeCursor(lastTS) + } + + return &qbtypes.Result{ + Type: qbtypes.RequestTypeRaw, + Value: &qbtypes.RawData{ + QueryName: q.spec.Name, + Rows: rows, + NextCursor: nextCursor, + }, + Stats: qbtypes.ExecStats{ + RowsScanned: totalRows, + BytesScanned: totalBytes, + DurationMS: uint64(time.Since(start).Milliseconds()), + }, + }, nil +} + +func encodeCursor(tsMilli int64) string { + return base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(tsMilli, 10))) +} + +func decodeCursor(cur string) (int64, error) { + b, err := base64.StdEncoding.DecodeString(cur) + if err != nil { + return 0, err + } + return strconv.ParseInt(string(b), 10, 64) +} diff --git a/pkg/querier/clickhouse_query.go b/pkg/querier/clickhouse_query.go new file mode 100644 index 000000000000..c5973ede379d --- /dev/null +++ b/pkg/querier/clickhouse_query.go @@ -0,0 +1,77 @@ +package querier + +import ( + "context" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/SigNoz/signoz/pkg/telemetrystore" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" +) + +type chSQLQuery struct { + telemetryStore telemetrystore.TelemetryStore + + query qbtypes.ClickHouseQuery + args []any + fromMS uint64 + toMS uint64 + kind qbtypes.RequestType +} + +var _ qbtypes.Query = (*chSQLQuery)(nil) + +func newchSQLQuery( + telemetryStore telemetrystore.TelemetryStore, + query qbtypes.ClickHouseQuery, + args []any, + tr qbtypes.TimeRange, + kind qbtypes.RequestType, +) *chSQLQuery { + return &chSQLQuery{ + telemetryStore: telemetryStore, + query: query, + args: args, + fromMS: tr.From, + toMS: tr.To, + kind: kind, + } +} + +// TODO: use the same query hash scheme as ClickHouse +func (q *chSQLQuery) Fingerprint() string { return q.query.Query } +func (q *chSQLQuery) Window() (uint64, uint64) { return q.fromMS, q.toMS } + +func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) { + + totalRows := uint64(0) + totalBytes := uint64(0) + elapsed := time.Duration(0) + + ctx = clickhouse.Context(ctx, clickhouse.WithProgress(func(p *clickhouse.Progress) { + totalRows += p.Rows + totalBytes += p.Bytes + elapsed += p.Elapsed + })) + + rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, q.query.Query, q.args...) + if err != nil { + return nil, err + } + defer rows.Close() + + // TODO: map the errors from ClickHouse to our error types + payload, err := consume(rows, q.kind) + if err != nil { + return nil, err + } + return &qbtypes.Result{ + Type: q.kind, + Value: payload, + Stats: qbtypes.ExecStats{ + RowsScanned: totalRows, + BytesScanned: totalBytes, + DurationMS: uint64(elapsed.Milliseconds()), + }, + }, nil +} diff --git a/pkg/querier/consume.go b/pkg/querier/consume.go new file mode 100644 index 000000000000..ba51387812e7 --- /dev/null +++ b/pkg/querier/consume.go @@ -0,0 +1,373 @@ +package querier + +import ( + "fmt" + "math" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" +) + +var ( + aggRe = regexp.MustCompile(`^__result_(\d+)$`) +) + +// consume reads every row and shapes it into the payload expected for the +// given request type. +// +// * Time-series - []*qbtypes.TimeSeriesData +// * Scalar - []*qbtypes.ScalarData +// * Raw - []*qbtypes.RawData +// * Distribution- []*qbtypes.DistributionData +func consume(rows driver.Rows, kind qbtypes.RequestType) (any, error) { + var ( + payload any + err error + ) + + switch kind { + case qbtypes.RequestTypeTimeSeries: + payload, err = readAsTimeSeries(rows) + case qbtypes.RequestTypeScalar: + payload, err = readAsScalar(rows) + case qbtypes.RequestTypeRaw: + payload, err = readAsRaw(rows) + // TODO: add support for other request types + } + + return payload, err +} + +func readAsTimeSeries(rows driver.Rows) ([]*qbtypes.TimeSeriesData, error) { + + colTypes := rows.ColumnTypes() + colNames := rows.Columns() + + slots := make([]any, len(colTypes)) + numericColsCount := 0 + for i, ct := range colTypes { + slots[i] = reflect.New(ct.ScanType()).Interface() + if numericKind(ct.ScanType().Kind()) { + numericColsCount++ + } + } + + type sKey struct { + agg int + key string // deterministic join of label values + } + seriesMap := map[sKey]*qbtypes.TimeSeries{} + + for rows.Next() { + if err := rows.Scan(slots...); err != nil { + return nil, err + } + + var ( + ts int64 + lblVals []string + lblObjs []*qbtypes.Label + aggValues = map[int]float64{} // all __result_N in this row + fallbackValue float64 // value when NO __result_N columns exist + fallbackSeen bool + ) + + for idx, ptr := range slots { + name := colNames[idx] + + switch v := ptr.(type) { + case *time.Time: + ts = v.UnixMilli() + + case *float64, *float32, *int64, *int32, *uint64, *uint32: + val := numericAsFloat(reflect.ValueOf(ptr).Elem().Interface()) + if m := aggRe.FindStringSubmatch(name); m != nil { + id, _ := strconv.Atoi(m[1]) + aggValues[id] = val + } else if numericColsCount == 1 { // classic single-value query + fallbackValue = val + fallbackSeen = true + } else { + // numeric label + lblVals = append(lblVals, fmt.Sprint(val)) + lblObjs = append(lblObjs, &qbtypes.Label{ + Key: telemetrytypes.TelemetryFieldKey{Name: name}, + Value: val, + }) + } + + case **float64, **float32, **int64, **int32, **uint64, **uint32: + tempVal := reflect.ValueOf(ptr) + if tempVal.IsValid() && !tempVal.IsNil() && !tempVal.Elem().IsNil() { + val := numericAsFloat(tempVal.Elem().Elem().Interface()) + if m := aggRe.FindStringSubmatch(name); m != nil { + id, _ := strconv.Atoi(m[1]) + aggValues[id] = val + } else if numericColsCount == 1 { // classic single-value query + fallbackValue = val + fallbackSeen = true + } else { + // numeric label + lblVals = append(lblVals, fmt.Sprint(val)) + lblObjs = append(lblObjs, &qbtypes.Label{ + Key: telemetrytypes.TelemetryFieldKey{Name: name}, + Value: val, + }) + } + } + + case *string: + lblVals = append(lblVals, *v) + lblObjs = append(lblObjs, &qbtypes.Label{ + Key: telemetrytypes.TelemetryFieldKey{Name: name}, + Value: *v, + }) + + case **string: + val := *v + if val == nil { + var empty string + val = &empty + } + lblVals = append(lblVals, *val) + lblObjs = append(lblObjs, &qbtypes.Label{ + Key: telemetrytypes.TelemetryFieldKey{Name: name}, + Value: val, + }) + + default: + continue + } + } + + // Edge-case: no __result_N columns, but a single numeric column present + if len(aggValues) == 0 && fallbackSeen { + aggValues[0] = fallbackValue + } + + if ts == 0 || len(aggValues) == 0 { + continue // nothing useful + } + + sort.Strings(lblVals) + labelsKey := strings.Join(lblVals, ",") + + // one point per aggregation in this row + for aggIdx, val := range aggValues { + if math.IsNaN(val) || math.IsInf(val, 0) { + continue + } + + key := sKey{agg: aggIdx, key: labelsKey} + + series, ok := seriesMap[key] + if !ok { + series = &qbtypes.TimeSeries{Labels: lblObjs} + seriesMap[key] = series + } + series.Values = append(series.Values, &qbtypes.TimeSeriesValue{ + Timestamp: ts, + Value: val, + }) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + + maxAgg := -1 + for k := range seriesMap { + if k.agg > maxAgg { + maxAgg = k.agg + } + } + if maxAgg < 0 { + return nil, nil // empty result-set + } + + buckets := make([]*qbtypes.AggregationBucket, maxAgg+1) + for i := range buckets { + buckets[i] = &qbtypes.AggregationBucket{ + Index: i, + Alias: "__result_" + strconv.Itoa(i), + } + } + for k, s := range seriesMap { + buckets[k.agg].Series = append(buckets[k.agg].Series, s) + } + + var nonEmpty []*qbtypes.AggregationBucket + for _, b := range buckets { + if len(b.Series) > 0 { + nonEmpty = append(nonEmpty, b) + } + } + + return []*qbtypes.TimeSeriesData{{ + Aggregations: nonEmpty, + }}, nil +} + +func numericKind(k reflect.Kind) bool { + switch k { + case reflect.Float32, reflect.Float64, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return true + default: + return false + } +} + +func readAsScalar(rows driver.Rows) (*qbtypes.ScalarData, error) { + colNames := rows.Columns() + colTypes := rows.ColumnTypes() + + cd := make([]*qbtypes.ColumnDescriptor, len(colNames)) + + for i, name := range colNames { + colType := qbtypes.ColumnTypeGroup + if aggRe.MatchString(name) { + colType = qbtypes.ColumnTypeAggregation + } + cd[i] = &qbtypes.ColumnDescriptor{ + TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name}, + AggregationIndex: int64(i), + Type: colType, + } + } + + var data [][]any + + for rows.Next() { + scan := make([]any, len(colTypes)) + for i := range scan { + scan[i] = reflect.New(colTypes[i].ScanType()).Interface() + } + if err := rows.Scan(scan...); err != nil { + return nil, err + } + + // 2. deref each slot into the output row + row := make([]any, len(scan)) + for i, cell := range scan { + valPtr := reflect.ValueOf(cell) + if valPtr.Kind() == reflect.Pointer && !valPtr.IsNil() { + row[i] = valPtr.Elem().Interface() + } else { + row[i] = nil // Nullable columns come back as nil pointers + } + } + data = append(data, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return &qbtypes.ScalarData{ + Columns: cd, + Data: data, + }, nil +} + +func readAsRaw(rows driver.Rows) (*qbtypes.RawData, error) { + + colNames := rows.Columns() + colTypes := rows.ColumnTypes() + colCnt := len(colNames) + + // Build a template slice of correctly-typed pointers once + scanTpl := make([]any, colCnt) + for i, ct := range colTypes { + scanTpl[i] = reflect.New(ct.ScanType()).Interface() + } + + var outRows []*qbtypes.RawRow + + for rows.Next() { + // fresh copy of the scan slice (otherwise the driver reuses pointers) + scan := make([]any, colCnt) + for i := range scanTpl { + scan[i] = reflect.New(colTypes[i].ScanType()).Interface() + } + + if err := rows.Scan(scan...); err != nil { + return nil, err + } + + rr := qbtypes.RawRow{ + Data: make(map[string]*any, colCnt), + } + + for i, cellPtr := range scan { + name := colNames[i] + + // de-reference the typed pointer to any + val := reflect.ValueOf(cellPtr).Elem().Interface() + + // special-case: timestamp column + if name == "timestamp" || name == "timestamp_datetime" { + switch t := val.(type) { + case time.Time: + rr.Timestamp = t + case uint64: // epoch-ns stored as integer + rr.Timestamp = time.Unix(0, int64(t)) + case int64: + rr.Timestamp = time.Unix(0, t) + default: + // leave zero time if unrecognised + } + } + + // store value in map as *any, to match the schema + v := any(val) + rr.Data[name] = &v + } + outRows = append(outRows, &rr) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return &qbtypes.RawData{ + Rows: outRows, + }, nil +} + +func numericAsFloat(v any) float64 { + switch x := v.(type) { + case float64: + return x + case float32: + return float64(x) + case int64: + return float64(x) + case int32: + return float64(x) + case int16: + return float64(x) + case int8: + return float64(x) + case int: + return float64(x) + case uint64: + return float64(x) + case uint32: + return float64(x) + case uint16: + return float64(x) + case uint8: + return float64(x) + case uint: + return float64(x) + default: + return math.NaN() + } +} diff --git a/pkg/querier/list_range.go b/pkg/querier/list_range.go new file mode 100644 index 000000000000..b62f932ba02f --- /dev/null +++ b/pkg/querier/list_range.go @@ -0,0 +1,36 @@ +package querier + +import "github.com/SigNoz/signoz/pkg/querybuilder" + +const hourNanos = int64(3_600_000_000_000) // 1 h in ns + +type tsRange struct{ fromNS, toNS uint64 } + +// slice the timerange into exponentially growing buckets +func makeBuckets(start, end uint64) []tsRange { + startNS := querybuilder.ToNanoSecs(start) + endNS := querybuilder.ToNanoSecs(end) + + if endNS-startNS <= uint64(hourNanos) { + return []tsRange{{fromNS: startNS, toNS: endNS}} + } + + var out []tsRange + bucket := uint64(hourNanos) + curEnd := endNS + + for { + curStart := curEnd - bucket + if curStart < startNS { + curStart = startNS + } + out = append(out, tsRange{fromNS: curStart, toNS: curEnd}) + + if curStart == startNS { + break + } + curEnd = curStart + bucket *= 2 + } + return out +} diff --git a/pkg/querier/promql_query.go b/pkg/querier/promql_query.go new file mode 100644 index 000000000000..2934563749ff --- /dev/null +++ b/pkg/querier/promql_query.go @@ -0,0 +1,41 @@ +package querier + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/prometheus" + qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" +) + +type promqlQuery struct { + promEngine prometheus.Prometheus + query qbv5.PromQuery + tr qbv5.TimeRange + requestType qbv5.RequestType +} + +var _ qbv5.Query = (*promqlQuery)(nil) + +func newPromqlQuery( + promEngine prometheus.Prometheus, + query qbv5.PromQuery, + tr qbv5.TimeRange, + requestType qbv5.RequestType, +) *promqlQuery { + return &promqlQuery{promEngine, query, tr, requestType} +} + +func (q *promqlQuery) Fingerprint() string { + // TODO: Implement this + return "" +} + +func (q *promqlQuery) Window() (uint64, uint64) { + return q.tr.From, q.tr.To +} + +func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) { + // TODO: Implement this + //nolint:nilnil + return nil, nil +} diff --git a/pkg/querier/querier.go b/pkg/querier/querier.go new file mode 100644 index 000000000000..070b480ce3ee --- /dev/null +++ b/pkg/querier/querier.go @@ -0,0 +1,96 @@ +package querier + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/prometheus" + "github.com/SigNoz/signoz/pkg/telemetrystore" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" +) + +type querier struct { + telemetryStore telemetrystore.TelemetryStore + metadataStore telemetrytypes.MetadataStore + promEngine prometheus.Prometheus + traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation] + logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation] + metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation] +} + +func NewQuerier( + telemetryStore telemetrystore.TelemetryStore, + metadataStore telemetrytypes.MetadataStore, + promEngine prometheus.Prometheus, + traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], + logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], + metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], +) *querier { + return &querier{ + telemetryStore: telemetryStore, + metadataStore: metadataStore, + promEngine: promEngine, + traceStmtBuilder: traceStmtBuilder, + logStmtBuilder: logStmtBuilder, + metricStmtBuilder: metricStmtBuilder, + } +} + +func (q *querier) QueryRange(ctx context.Context, orgID string, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) { + + queries := make(map[string]qbtypes.Query) + + for _, query := range req.CompositeQuery.Queries { + switch query.Type { + case qbtypes.QueryTypePromQL: + promQuery, ok := query.Spec.(qbtypes.PromQuery) + if !ok { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec) + } + promqlQuery := newPromqlQuery(q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType) + queries[query.Name] = promqlQuery + case qbtypes.QueryTypeClickHouseSQL: + chQuery, ok := query.Spec.(qbtypes.ClickHouseQuery) + if !ok { + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec) + } + chSQLQuery := newchSQLQuery(q.telemetryStore, chQuery, nil, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType) + queries[query.Name] = chSQLQuery + case qbtypes.QueryTypeBuilder: + switch spec := query.Spec.(type) { + case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]: + bq := newBuilderQuery(q.telemetryStore, q.traceStmtBuilder, spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType) + queries[query.Name] = bq + + case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]: + bq := newBuilderQuery(q.telemetryStore, q.logStmtBuilder, spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType) + queries[query.Name] = bq + + case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]: + bq := newBuilderQuery(q.telemetryStore, q.metricStmtBuilder, spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType) + queries[query.Name] = bq + default: + return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec) + } + } + } + return q.run(ctx, orgID, queries, req.RequestType) +} + +func (q *querier) run(ctx context.Context, _ string, qs map[string]qbtypes.Query, kind qbtypes.RequestType) (*qbtypes.QueryRangeResponse, error) { + results := make([]*qbtypes.Result, 0, len(qs)) + for _, query := range qs { + // TODO: run in controlled batches + result, err := query.Execute(ctx) + if err != nil { + return nil, err + } + results = append(results, result) + } + return &qbtypes.QueryRangeResponse{ + Type: kind, + Data: results, + }, nil +} diff --git a/pkg/querybuilder/agg_funcs.go b/pkg/querybuilder/agg_funcs.go index 80279c5a701f..4879d923cb66 100644 --- a/pkg/querybuilder/agg_funcs.go +++ b/pkg/querybuilder/agg_funcs.go @@ -13,6 +13,7 @@ type AggrFunc struct { FuncName string Aliases []valuer.String RequireArgs bool + Numeric bool FuncCombinator bool Rate bool MinArgs int @@ -46,156 +47,156 @@ var ( AggrFuncSum = AggrFunc{ Name: valuer.NewString("sum"), FuncName: "sum", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncSumIf = AggrFunc{ Name: valuer.NewString("sumif"), FuncName: "sumIf", Aliases: []valuer.String{valuer.NewString("sum_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncAvg = AggrFunc{ Name: valuer.NewString("avg"), FuncName: "avg", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncAvgIf = AggrFunc{ Name: valuer.NewString("avgif"), FuncName: "avgIf", Aliases: []valuer.String{valuer.NewString("avg_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncMin = AggrFunc{ Name: valuer.NewString("min"), FuncName: "min", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncMinIf = AggrFunc{ Name: valuer.NewString("minif"), FuncName: "minIf", Aliases: []valuer.String{valuer.NewString("min_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncMax = AggrFunc{ Name: valuer.NewString("max"), FuncName: "max", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncMaxIf = AggrFunc{ Name: valuer.NewString("maxif"), FuncName: "maxIf", Aliases: []valuer.String{valuer.NewString("max_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP05 = AggrFunc{ Name: valuer.NewString("p05"), FuncName: "quantile(0.05)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP05IF = AggrFunc{ Name: valuer.NewString("p05if"), FuncName: "quantileIf(0.05)", Aliases: []valuer.String{valuer.NewString("p05_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP10 = AggrFunc{ Name: valuer.NewString("p10"), FuncName: "quantile(0.10)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP10IF = AggrFunc{ Name: valuer.NewString("p10if"), FuncName: "quantileIf(0.10)", Aliases: []valuer.String{valuer.NewString("p10_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP20 = AggrFunc{ Name: valuer.NewString("p20"), FuncName: "quantile(0.20)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP20IF = AggrFunc{ Name: valuer.NewString("p20if"), FuncName: "quantileIf(0.20)", Aliases: []valuer.String{valuer.NewString("p20_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP25 = AggrFunc{ Name: valuer.NewString("p25"), FuncName: "quantile(0.25)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP25IF = AggrFunc{ Name: valuer.NewString("p25if"), FuncName: "quantileIf(0.25)", Aliases: []valuer.String{valuer.NewString("p25_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP50 = AggrFunc{ Name: valuer.NewString("p50"), FuncName: "quantile(0.50)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP50IF = AggrFunc{ Name: valuer.NewString("p50if"), FuncName: "quantileIf(0.50)", Aliases: []valuer.String{valuer.NewString("p50_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP75 = AggrFunc{ Name: valuer.NewString("p75"), FuncName: "quantile(0.75)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP75IF = AggrFunc{ Name: valuer.NewString("p75if"), FuncName: "quantileIf(0.75)", Aliases: []valuer.String{valuer.NewString("p75_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP90 = AggrFunc{ Name: valuer.NewString("p90"), FuncName: "quantile(0.90)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP90IF = AggrFunc{ Name: valuer.NewString("p90if"), FuncName: "quantileIf(0.90)", Aliases: []valuer.String{valuer.NewString("p90_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP95 = AggrFunc{ Name: valuer.NewString("p95"), FuncName: "quantile(0.95)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP95IF = AggrFunc{ Name: valuer.NewString("p95if"), FuncName: "quantileIf(0.95)", Aliases: []valuer.String{valuer.NewString("p95_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP99 = AggrFunc{ Name: valuer.NewString("p99"), FuncName: "quantile(0.99)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP99IF = AggrFunc{ Name: valuer.NewString("p99if"), FuncName: "quantileIf(0.99)", Aliases: []valuer.String{valuer.NewString("p99_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncP999 = AggrFunc{ Name: valuer.NewString("p999"), FuncName: "quantile(0.999)", - RequireArgs: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, MinArgs: 1, MaxArgs: 1, } AggrFuncP999IF = AggrFunc{ Name: valuer.NewString("p999if"), FuncName: "quantileIf(0.999)", Aliases: []valuer.String{valuer.NewString("p999_if")}, - RequireArgs: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, + RequireArgs: true, Numeric: true, FuncCombinator: true, MinArgs: 2, MaxArgs: 2, } AggrFuncRate = AggrFunc{ Name: valuer.NewString("rate"), @@ -211,22 +212,22 @@ var ( AggrFuncRateSum = AggrFunc{ Name: valuer.NewString("rate_sum"), FuncName: "sum", - RequireArgs: true, Rate: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, Rate: true, MinArgs: 1, MaxArgs: 1, } AggrFuncRateAvg = AggrFunc{ Name: valuer.NewString("rate_avg"), FuncName: "avg", - RequireArgs: true, Rate: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, Rate: true, MinArgs: 1, MaxArgs: 1, } AggrFuncRateMin = AggrFunc{ Name: valuer.NewString("rate_min"), FuncName: "min", - RequireArgs: true, Rate: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, Rate: true, MinArgs: 1, MaxArgs: 1, } AggrFuncRateMax = AggrFunc{ Name: valuer.NewString("rate_max"), FuncName: "max", - RequireArgs: true, Rate: true, MinArgs: 1, MaxArgs: 1, + RequireArgs: true, Numeric: true, Rate: true, MinArgs: 1, MaxArgs: 1, } ) diff --git a/pkg/querybuilder/agg_rewrite.go b/pkg/querybuilder/agg_rewrite.go index 052831d31694..1b661a388220 100644 --- a/pkg/querybuilder/agg_rewrite.go +++ b/pkg/querybuilder/agg_rewrite.go @@ -13,33 +13,41 @@ import ( "github.com/huandu/go-sqlbuilder" ) -type AggExprRewriterOptions struct { - MetadataStore telemetrytypes.MetadataStore - FullTextColumn *telemetrytypes.TelemetryFieldKey - FieldMapper qbtypes.FieldMapper - ConditionBuilder qbtypes.ConditionBuilder - FilterCompiler qbtypes.FilterCompiler - JsonBodyPrefix string - JsonKeyToKey qbtypes.JsonKeyToFieldFunc -} - type aggExprRewriter struct { - opts AggExprRewriterOptions + fullTextColumn *telemetrytypes.TelemetryFieldKey + fieldMapper qbtypes.FieldMapper + conditionBuilder qbtypes.ConditionBuilder + jsonBodyPrefix string + jsonKeyToKey qbtypes.JsonKeyToFieldFunc } -func NewAggExprRewriter(opts AggExprRewriterOptions) *aggExprRewriter { - return &aggExprRewriter{opts: opts} +var _ qbtypes.AggExprRewriter = (*aggExprRewriter)(nil) + +func NewAggExprRewriter( + fullTextColumn *telemetrytypes.TelemetryFieldKey, + fieldMapper qbtypes.FieldMapper, + conditionBuilder qbtypes.ConditionBuilder, + jsonBodyPrefix string, + jsonKeyToKey qbtypes.JsonKeyToFieldFunc, +) *aggExprRewriter { + return &aggExprRewriter{ + fullTextColumn: fullTextColumn, + fieldMapper: fieldMapper, + conditionBuilder: conditionBuilder, + jsonBodyPrefix: jsonBodyPrefix, + jsonKeyToKey: jsonKeyToKey, + } } // Rewrite parses the given aggregation expression, maps the column, and condition to // valid data source column and condition expression, and returns the rewritten expression // and the args if the parametric aggregation function is used. -func (r *aggExprRewriter) Rewrite(ctx context.Context, expr string, opts ...qbtypes.RewriteOption) (string, []any, error) { - - rctx := &qbtypes.RewriteCtx{} - for _, opt := range opts { - opt(rctx) - } +func (r *aggExprRewriter) Rewrite( + ctx context.Context, + expr string, + rateInterval uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, +) (string, []any, error) { wrapped := fmt.Sprintf("SELECT %s", expr) p := chparser.NewParser(wrapped) @@ -62,19 +70,12 @@ func (r *aggExprRewriter) Rewrite(ctx context.Context, expr string, opts ...qbty return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr) } - selectors := QueryStringToKeysSelectors(expr) - - keys, err := r.opts.MetadataStore.GetKeysMulti(ctx, selectors) - if err != nil { - return "", nil, err - } - visitor := newExprVisitor(keys, - r.opts.FullTextColumn, - r.opts.FieldMapper, - r.opts.ConditionBuilder, - r.opts.JsonBodyPrefix, - r.opts.JsonKeyToKey, + r.fullTextColumn, + r.fieldMapper, + r.conditionBuilder, + r.jsonBodyPrefix, + r.jsonKeyToKey, ) // Rewrite the first select item (our expression) if err := sel.SelectItems[0].Accept(visitor); err != nil { @@ -82,26 +83,23 @@ func (r *aggExprRewriter) Rewrite(ctx context.Context, expr string, opts ...qbty } if visitor.isRate { - return fmt.Sprintf("%s/%d", sel.SelectItems[0].String(), rctx.RateInterval), visitor.chArgs, nil + return fmt.Sprintf("%s/%d", sel.SelectItems[0].String(), rateInterval), visitor.chArgs, nil } return sel.SelectItems[0].String(), visitor.chArgs, nil } -// RewriteMultiple rewrites a slice of expressions. -func (r *aggExprRewriter) RewriteMultiple( +// RewriteMulti rewrites a slice of expressions. +func (r *aggExprRewriter) RewriteMulti( ctx context.Context, exprs []string, - opts ...qbtypes.RewriteOption, + rateInterval uint64, + keys map[string][]*telemetrytypes.TelemetryFieldKey, ) ([]string, [][]any, error) { - rctx := &qbtypes.RewriteCtx{} - for _, opt := range opts { - opt(rctx) - } out := make([]string, len(exprs)) var errs []error var chArgsList [][]any for i, e := range exprs { - w, chArgs, err := r.Rewrite(ctx, e, opts...) + w, chArgs, err := r.Rewrite(ctx, e, rateInterval, keys) if err != nil { errs = append(errs, err) out[i] = e @@ -173,6 +171,11 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error { v.isRate = true } + dataType := telemetrytypes.FieldDataTypeString + if aggFunc.Numeric { + dataType = telemetrytypes.FieldDataTypeFloat64 + } + // Handle *If functions with predicate + values if aggFunc.FuncCombinator { // Map the predicate (last argument) @@ -205,11 +208,13 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error { // Map each value column argument for i := 0; i < len(args)-1; i++ { origVal := args[i].String() - colName, err := v.fieldMapper.ColumnExpressionFor(context.Background(), &telemetrytypes.TelemetryFieldKey{Name: origVal}, v.fieldKeys) + fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal) + expr, exprArgs, err := CollisionHandledFinalExpr(context.Background(), &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType) if err != nil { return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal) } - newVal := colName + v.chArgs = append(v.chArgs, exprArgs...) + newVal := expr parsedVal, err := parseFragment(newVal) if err != nil { return err @@ -221,11 +226,13 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error { // Non-If functions: map every argument as a column/value for i, arg := range args { orig := arg.String() - colName, err := v.fieldMapper.ColumnExpressionFor(context.Background(), &telemetrytypes.TelemetryFieldKey{Name: orig}, v.fieldKeys) + fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig) + expr, exprArgs, err := CollisionHandledFinalExpr(context.Background(), &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType) if err != nil { return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", orig) } - newCol := colName + v.chArgs = append(v.chArgs, exprArgs...) + newCol := expr parsed, err := parseFragment(newCol) if err != nil { return err diff --git a/pkg/querybuilder/cte.go b/pkg/querybuilder/cte.go new file mode 100644 index 000000000000..e3da7828c24d --- /dev/null +++ b/pkg/querybuilder/cte.go @@ -0,0 +1,27 @@ +package querybuilder + +import ( + "strings" +) + +// combineCTEs takes any number of individual CTE fragments like +// +// "__resource_filter AS (...)", "__limit_cte AS (...)" +// +// and renders the final `WITH …` clause. +func CombineCTEs(ctes []string) string { + if len(ctes) == 0 { + return "" + } + return "WITH " + strings.Join(ctes, ", ") + " " +} + +// prependArgs ensures CTE arguments appear before main-query arguments +// in the final slice so their ordinal positions match the SQL string. +func PrependArgs(cteArgs [][]any, mainArgs []any) []any { + out := make([]any, 0, len(mainArgs)+len(cteArgs)) + for _, a := range cteArgs { // CTEs first, in declaration order + out = append(out, a...) + } + return append(out, mainArgs...) +} diff --git a/pkg/querybuilder/fallback_expr.go b/pkg/querybuilder/fallback_expr.go new file mode 100644 index 000000000000..3001cc3fcde9 --- /dev/null +++ b/pkg/querybuilder/fallback_expr.go @@ -0,0 +1,96 @@ +package querybuilder + +import ( + "context" + "fmt" + "strings" + + "github.com/SigNoz/signoz/pkg/errors" + qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" + "github.com/SigNoz/signoz/pkg/types/telemetrytypes" + "github.com/huandu/go-sqlbuilder" + "golang.org/x/exp/maps" +) + +func CollisionHandledFinalExpr( + ctx context.Context, + field *telemetrytypes.TelemetryFieldKey, + fm qbtypes.FieldMapper, + cb qbtypes.ConditionBuilder, + keys map[string][]*telemetrytypes.TelemetryFieldKey, + requiredDataType telemetrytypes.FieldDataType, +) (string, []any, error) { + + if requiredDataType != telemetrytypes.FieldDataTypeString && + requiredDataType != telemetrytypes.FieldDataTypeFloat64 { + return "", nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unsupported data type %s", requiredDataType) + } + + var dummyValue any + if requiredDataType == telemetrytypes.FieldDataTypeFloat64 { + dummyValue = 0.0 + } else { + dummyValue = "" + } + + var stmts []string + var allArgs []any + + addCondition := func(key *telemetrytypes.TelemetryFieldKey) error { + sb := sqlbuilder.NewSelectBuilder() + condition, err := cb.ConditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb) + if err != nil { + return err + } + sb.Where(condition) + + expr, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse) + expr = strings.TrimPrefix(expr, "WHERE ") + stmts = append(stmts, expr) + allArgs = append(allArgs, args...) + return nil + } + + colName, err := fm.FieldFor(ctx, field) + if errors.Is(err, qbtypes.ErrColumnNotFound) { + // the key didn't have the right context to be added to the query + // we try to use the context we know of + keysForField := keys[field.Name] + if len(keysForField) == 0 { + // - the context is not provided + // - there are not keys for the field + // - it is not a static field + // - the next best thing to do is see if there is a typo + // and suggest a correction + correction, found := telemetrytypes.SuggestCorrection(field.Name, maps.Keys(keys)) + if found { + // we found a close match, in the error message send the suggestion + return "", nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, correction) + } else { + // not even a close match, return an error + return "", nil, err + } + } else { + for _, key := range keysForField { + err := addCondition(key) + if err != nil { + return "", nil, err + } + colName, _ = fm.FieldFor(ctx, key) + colName, _ = telemetrytypes.DataTypeCollisionHandledFieldName(key, dummyValue, colName) + stmts = append(stmts, colName) + } + } + } else { + err := addCondition(field) + if err != nil { + return "", nil, err + } + colName, _ = telemetrytypes.DataTypeCollisionHandledFieldName(field, dummyValue, colName) + stmts = append(stmts, colName) + } + + multiIfStmt := fmt.Sprintf("multiIf(%s, NULL)", strings.Join(stmts, ", ")) + + return multiIfStmt, allArgs, nil +} diff --git a/pkg/querybuilder/resourcefilter/filter_compiler.go b/pkg/querybuilder/resourcefilter/filter_compiler.go deleted file mode 100644 index bf75b24bf020..000000000000 --- a/pkg/querybuilder/resourcefilter/filter_compiler.go +++ /dev/null @@ -1,47 +0,0 @@ -package resourcefilter - -import ( - "context" - - "github.com/SigNoz/signoz/pkg/querybuilder" - qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" - "github.com/SigNoz/signoz/pkg/types/telemetrytypes" - "github.com/huandu/go-sqlbuilder" -) - -type FilterCompilerOpts struct { - FieldMapper qbtypes.FieldMapper - ConditionBuilder qbtypes.ConditionBuilder - MetadataStore telemetrytypes.MetadataStore -} - -type filterCompiler struct { - opts FilterCompilerOpts -} - -func NewFilterCompiler(opts FilterCompilerOpts) *filterCompiler { - return &filterCompiler{ - opts: opts, - } -} - -func (c *filterCompiler) Compile(ctx context.Context, expr string) (*sqlbuilder.WhereClause, []string, error) { - selectors := querybuilder.QueryStringToKeysSelectors(expr) - - keys, err := c.opts.MetadataStore.GetKeysMulti(ctx, selectors) - if err != nil { - return nil, nil, err - } - - filterWhereClause, warnings, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{ - FieldMapper: c.opts.FieldMapper, - ConditionBuilder: c.opts.ConditionBuilder, - FieldKeys: keys, - }) - - if err != nil { - return nil, nil, err - } - - return filterWhereClause, warnings, nil -} diff --git a/pkg/querybuilder/resourcefilter/statement_builder.go b/pkg/querybuilder/resourcefilter/statement_builder.go index f4afcb2e6e07..559c552f6629 100644 --- a/pkg/querybuilder/resourcefilter/statement_builder.go +++ b/pkg/querybuilder/resourcefilter/statement_builder.go @@ -5,17 +5,12 @@ import ( "fmt" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/querybuilder" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" "github.com/SigNoz/signoz/pkg/types/telemetrytypes" "github.com/huandu/go-sqlbuilder" ) -type ResourceFilterStatementBuilderOpts struct { - FieldMapper qbtypes.FieldMapper - ConditionBuilder qbtypes.ConditionBuilder - Compiler qbtypes.FilterCompiler -} - var ( ErrUnsupportedSignal = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported signal type") ) @@ -39,8 +34,10 @@ var signalConfigs = map[telemetrytypes.Signal]signalConfig{ // Generic resource filter statement builder type resourceFilterStatementBuilder[T any] struct { - opts ResourceFilterStatementBuilderOpts - signal telemetrytypes.Signal + fieldMapper qbtypes.FieldMapper + conditionBuilder qbtypes.ConditionBuilder + metadataStore telemetrytypes.MetadataStore + signal telemetrytypes.Signal } // Ensure interface compliance at compile time @@ -50,20 +47,47 @@ var ( ) // Constructor functions -func NewTraceResourceFilterStatementBuilder(opts ResourceFilterStatementBuilderOpts) *resourceFilterStatementBuilder[qbtypes.TraceAggregation] { +func NewTraceResourceFilterStatementBuilder( + fieldMapper qbtypes.FieldMapper, + conditionBuilder qbtypes.ConditionBuilder, + metadataStore telemetrytypes.MetadataStore, +) *resourceFilterStatementBuilder[qbtypes.TraceAggregation] { return &resourceFilterStatementBuilder[qbtypes.TraceAggregation]{ - opts: opts, - signal: telemetrytypes.SignalTraces, + fieldMapper: fieldMapper, + conditionBuilder: conditionBuilder, + metadataStore: metadataStore, + signal: telemetrytypes.SignalTraces, } } -func NewLogResourceFilterStatementBuilder(opts ResourceFilterStatementBuilderOpts) *resourceFilterStatementBuilder[qbtypes.LogAggregation] { +func NewLogResourceFilterStatementBuilder( + fieldMapper qbtypes.FieldMapper, + conditionBuilder qbtypes.ConditionBuilder, + metadataStore telemetrytypes.MetadataStore, +) *resourceFilterStatementBuilder[qbtypes.LogAggregation] { return &resourceFilterStatementBuilder[qbtypes.LogAggregation]{ - opts: opts, - signal: telemetrytypes.SignalLogs, + fieldMapper: fieldMapper, + conditionBuilder: conditionBuilder, + metadataStore: metadataStore, + signal: telemetrytypes.SignalLogs, } } +func (b *resourceFilterStatementBuilder[T]) getKeySelectors(query qbtypes.QueryBuilderQuery[T]) []*telemetrytypes.FieldKeySelector { + var keySelectors []*telemetrytypes.FieldKeySelector + + if query.Filter != nil && query.Filter.Expression != "" { + whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) + keySelectors = append(keySelectors, whereClauseSelectors...) + } + + for idx := range keySelectors { + keySelectors[idx].Signal = b.signal + } + + return keySelectors +} + // Build builds a SQL query based on the given parameters func (b *resourceFilterStatementBuilder[T]) Build( ctx context.Context, @@ -77,15 +101,21 @@ func (b *resourceFilterStatementBuilder[T]) Build( return nil, fmt.Errorf("%w: %s", ErrUnsupportedSignal, b.signal) } - q := sqlbuilder.ClickHouse.NewSelectBuilder() + q := sqlbuilder.NewSelectBuilder() q.Select("fingerprint") q.From(fmt.Sprintf("%s.%s", config.dbName, config.tableName)) - if err := b.addConditions(ctx, q, start, end, query); err != nil { + keySelectors := b.getKeySelectors(query) + keys, err := b.metadataStore.GetKeysMulti(ctx, keySelectors) + if err != nil { return nil, err } - stmt, args := q.Build() + if err := b.addConditions(ctx, q, start, end, query, keys); err != nil { + return nil, err + } + + stmt, args := q.BuildWithFlavor(sqlbuilder.ClickHouse) return &qbtypes.Statement{ Query: stmt, Args: args, @@ -94,14 +124,22 @@ func (b *resourceFilterStatementBuilder[T]) Build( // addConditions adds both filter and time conditions to the query func (b *resourceFilterStatementBuilder[T]) addConditions( - ctx context.Context, + _ context.Context, sb *sqlbuilder.SelectBuilder, start, end uint64, query qbtypes.QueryBuilderQuery[T], + keys map[string][]*telemetrytypes.TelemetryFieldKey, ) error { // Add filter condition if present if query.Filter != nil && query.Filter.Expression != "" { - filterWhereClause, _, err := b.opts.Compiler.Compile(ctx, query.Filter.Expression) + + // warnings would be encountered as part of the main condition already + filterWhereClause, _, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{ + FieldMapper: b.fieldMapper, + ConditionBuilder: b.conditionBuilder, + FieldKeys: keys, + }) + if err != nil { return err } @@ -118,13 +156,9 @@ func (b *resourceFilterStatementBuilder[T]) addConditions( // addTimeFilter adds time-based filtering conditions func (b *resourceFilterStatementBuilder[T]) addTimeFilter(sb *sqlbuilder.SelectBuilder, start, end uint64) { // Convert nanoseconds to seconds and adjust start bucket - const ( - nsToSeconds = 1000000000 - bucketAdjustment = 1800 // 30 minutes - ) - startBucket := start/nsToSeconds - bucketAdjustment - endBucket := end / nsToSeconds + startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment + endBucket := end / querybuilder.NsToSeconds sb.Where( sb.GE("seen_at_ts_bucket_start", startBucket), diff --git a/pkg/querybuilder/time.go b/pkg/querybuilder/time.go index 4c9fe46f0466..02293da81e16 100644 --- a/pkg/querybuilder/time.go +++ b/pkg/querybuilder/time.go @@ -2,6 +2,11 @@ package querybuilder import "math" +const ( + NsToSeconds = 1000000000 + BucketAdjustment = 1800 // 30 minutes +) + // ToNanoSecs takes epoch and returns it in ns func ToNanoSecs(epoch uint64) uint64 { temp := epoch diff --git a/pkg/telemetrylogs/condition_builder.go b/pkg/telemetrylogs/condition_builder.go index da171ca51ffa..d3329f81dfb3 100644 --- a/pkg/telemetrylogs/condition_builder.go +++ b/pkg/telemetrylogs/condition_builder.go @@ -147,6 +147,11 @@ func (c *conditionBuilder) conditionFor( } } + // if the field is intrinsic, it always exists + if slices.Contains(IntrinsicFields, key.Name) { + return "true", nil + } + var value any switch column.Type { case schema.ColumnTypeString, schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}: diff --git a/pkg/telemetrylogs/condition_builder_test.go b/pkg/telemetrylogs/condition_builder_test.go index 45f61b2d035d..f3519969ba7d 100644 --- a/pkg/telemetrylogs/condition_builder_test.go +++ b/pkg/telemetrylogs/condition_builder_test.go @@ -249,8 +249,7 @@ func TestConditionFor(t *testing.T) { }, operator: qbtypes.FilterOperatorExists, value: nil, - expectedSQL: "body <> ?", - expectedArgs: []any{""}, + expectedSQL: "true", expectedError: nil, }, { @@ -261,8 +260,7 @@ func TestConditionFor(t *testing.T) { }, operator: qbtypes.FilterOperatorNotExists, value: nil, - expectedSQL: "body = ?", - expectedArgs: []any{""}, + expectedSQL: "true", expectedError: nil, }, { @@ -273,8 +271,7 @@ func TestConditionFor(t *testing.T) { }, operator: qbtypes.FilterOperatorExists, value: nil, - expectedSQL: "timestamp <> ?", - expectedArgs: []any{0}, + expectedSQL: "true", expectedError: nil, }, { diff --git a/pkg/telemetrylogs/statement_builder.go b/pkg/telemetrylogs/statement_builder.go index 1806624a637e..a0bdea797811 100644 --- a/pkg/telemetrylogs/statement_builder.go +++ b/pkg/telemetrylogs/statement_builder.go @@ -3,6 +3,7 @@ package telemetrylogs import ( "context" "fmt" + "log/slog" "strings" "github.com/SigNoz/signoz/pkg/errors" @@ -16,32 +17,32 @@ var ( ErrUnsupportedAggregation = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported aggregation") ) -type LogQueryStatementBuilderOpts struct { - MetadataStore telemetrytypes.MetadataStore - FieldMapper qbtypes.FieldMapper - ConditionBuilder qbtypes.ConditionBuilder - ResourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation] - Compiler qbtypes.FilterCompiler - AggExprRewriter qbtypes.AggExprRewriter -} - type logQueryStatementBuilder struct { - opts LogQueryStatementBuilderOpts - fm qbtypes.FieldMapper - cb qbtypes.ConditionBuilder - compiler qbtypes.FilterCompiler - aggExprRewriter qbtypes.AggExprRewriter + logger *slog.Logger + metadataStore telemetrytypes.MetadataStore + fm qbtypes.FieldMapper + cb qbtypes.ConditionBuilder + resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation] + aggExprRewriter qbtypes.AggExprRewriter } var _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*logQueryStatementBuilder)(nil) -func NewLogQueryStatementBuilder(opts LogQueryStatementBuilderOpts) *logQueryStatementBuilder { +func NewLogQueryStatementBuilder( + logger *slog.Logger, + metadataStore telemetrytypes.MetadataStore, + fieldMapper qbtypes.FieldMapper, + conditionBuilder qbtypes.ConditionBuilder, + resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], + aggExprRewriter qbtypes.AggExprRewriter, +) *logQueryStatementBuilder { return &logQueryStatementBuilder{ - opts: opts, - fm: opts.FieldMapper, - cb: opts.ConditionBuilder, - compiler: opts.Compiler, - aggExprRewriter: opts.AggExprRewriter, + logger: logger, + metadataStore: metadataStore, + fm: fieldMapper, + cb: conditionBuilder, + resourceFilterStmtBuilder: resourceFilterStmtBuilder, + aggExprRewriter: aggExprRewriter, } } @@ -58,13 +59,13 @@ func (b *logQueryStatementBuilder) Build( end = querybuilder.ToNanoSecs(end) keySelectors := getKeySelectors(query) - keys, err := b.opts.MetadataStore.GetKeysMulti(ctx, keySelectors) + keys, err := b.metadataStore.GetKeysMulti(ctx, keySelectors) if err != nil { return nil, err } // Create SQL builder - q := sqlbuilder.ClickHouse.NewSelectBuilder() + q := sqlbuilder.NewSelectBuilder() switch requestType { case qbtypes.RequestTypeRaw: @@ -87,8 +88,29 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) [] keySelectors = append(keySelectors, selectors...) } - whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) - keySelectors = append(keySelectors, whereClauseSelectors...) + if query.Filter != nil && query.Filter.Expression != "" { + whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) + keySelectors = append(keySelectors, whereClauseSelectors...) + } + + for idx := range query.GroupBy { + groupBy := query.GroupBy[idx] + selectors := querybuilder.QueryStringToKeysSelectors(groupBy.TelemetryFieldKey.Name) + keySelectors = append(keySelectors, selectors...) + } + + for idx := range query.Order { + keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{ + Name: query.Order[idx].Key.Name, + Signal: telemetrytypes.SignalTraces, + FieldContext: query.Order[idx].Key.FieldContext, + FieldDataType: query.Order[idx].Key.FieldDataType, + }) + } + + for idx := range keySelectors { + keySelectors[idx].Signal = telemetrytypes.SignalLogs + } return keySelectors } @@ -99,7 +121,7 @@ func (b *logQueryStatementBuilder) buildListQuery( sb *sqlbuilder.SelectBuilder, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], start, end uint64, - _ map[string][]*telemetrytypes.TelemetryFieldKey, + keys map[string][]*telemetrytypes.TelemetryFieldKey, ) (*qbtypes.Statement, error) { var ( @@ -123,7 +145,7 @@ func (b *logQueryStatementBuilder) buildListQuery( sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName)) // Add filter conditions - warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys) if err != nil { return nil, err } @@ -144,8 +166,8 @@ func (b *logQueryStatementBuilder) buildListQuery( mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse) - finalSQL := combineCTEs(cteFragments) + mainSQL - finalArgs := prependArgs(cteArgs, mainArgs) + finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs) return &qbtypes.Statement{ Query: finalSQL, @@ -179,21 +201,29 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery( int64(query.StepInterval.Seconds()), )) + var allGroupByArgs []any + // Keep original column expressions so we can build the tuple fieldNames := make([]string, 0, len(query.GroupBy)) for _, gb := range query.GroupBy { - colExpr, err := b.fm.ColumnExpressionFor(ctx, &gb.TelemetryFieldKey, keys) + expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString) if err != nil { return nil, err } - sb.SelectMore(colExpr) + colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.TelemetryFieldKey.Name) + allGroupByArgs = append(allGroupByArgs, args...) + sb.SelectMore(sqlbuilder.Escape(colExpr)) fieldNames = append(fieldNames, fmt.Sprintf("`%s`", gb.TelemetryFieldKey.Name)) } // Aggregations allAggChArgs := make([]any, 0) for i, agg := range query.Aggregations { - rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, agg.Expression) + rewritten, chArgs, err := b.aggExprRewriter.Rewrite( + ctx, agg.Expression, + uint64(query.StepInterval.Seconds()), + keys, + ) if err != nil { return nil, err } @@ -202,7 +232,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery( } sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName)) - warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys) if err != nil { return nil, err } @@ -212,7 +242,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery( if query.Limit > 0 { // build the scalar “top/bottom-N” query in its own builder. - cteSB := sqlbuilder.ClickHouse.NewSelectBuilder() + cteSB := sqlbuilder.NewSelectBuilder() cteStmt, err := b.buildScalarQuery(ctx, cteSB, query, start, end, keys, true) if err != nil { return nil, err @@ -231,11 +261,13 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery( sb.Having(query.Having.Expression) } - mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + combinedArgs := append(allGroupByArgs, allAggChArgs...) + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...) // Stitch it all together: WITH … SELECT … - finalSQL = combineCTEs(cteFragments) + mainSQL - finalArgs = prependArgs(cteArgs, mainArgs) + finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs) } else { sb.GroupBy("ALL") @@ -243,11 +275,13 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery( sb.Having(query.Having.Expression) } - mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + combinedArgs := append(allGroupByArgs, allAggChArgs...) + + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...) // Stitch it all together: WITH … SELECT … - finalSQL = combineCTEs(cteFragments) + mainSQL - finalArgs = prependArgs(cteArgs, mainArgs) + finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs) } return &qbtypes.Statement{ @@ -281,20 +315,30 @@ func (b *logQueryStatementBuilder) buildScalarQuery( allAggChArgs := []any{} - // Add group by columns - for _, groupBy := range query.GroupBy { - colExpr, err := b.fm.ColumnExpressionFor(ctx, &groupBy.TelemetryFieldKey, keys) + var allGroupByArgs []any + + for _, gb := range query.GroupBy { + expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString) if err != nil { return nil, err } - sb.SelectMore(colExpr) + colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.TelemetryFieldKey.Name) + allGroupByArgs = append(allGroupByArgs, args...) + sb.SelectMore(sqlbuilder.Escape(colExpr)) } + // for scalar queries, the rate would be end-start + rateInterval := (end - start) / querybuilder.NsToSeconds + // Add aggregation if len(query.Aggregations) > 0 { for idx := range query.Aggregations { aggExpr := query.Aggregations[idx] - rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, aggExpr.Expression) + rewritten, chArgs, err := b.aggExprRewriter.Rewrite( + ctx, aggExpr.Expression, + rateInterval, + keys, + ) if err != nil { return nil, err } @@ -307,7 +351,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery( sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName)) // Add filter conditions - warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys) if err != nil { return nil, err } @@ -340,10 +384,12 @@ func (b *logQueryStatementBuilder) buildScalarQuery( sb.Limit(query.Limit) } - mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + combinedArgs := append(allGroupByArgs, allAggChArgs...) - finalSQL := combineCTEs(cteFragments) + mainSQL - finalArgs := prependArgs(cteArgs, mainArgs) + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...) + + finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs) return &qbtypes.Statement{ Query: finalSQL, @@ -353,11 +399,21 @@ func (b *logQueryStatementBuilder) buildScalarQuery( } // buildFilterCondition builds SQL condition from filter expression -func (b *logQueryStatementBuilder) addFilterCondition(ctx context.Context, sb *sqlbuilder.SelectBuilder, start, end uint64, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) ([]string, error) { +func (b *logQueryStatementBuilder) addFilterCondition( + _ context.Context, + sb *sqlbuilder.SelectBuilder, + start, end uint64, + query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], + keys map[string][]*telemetrytypes.TelemetryFieldKey, +) ([]string, error) { // add filter expression - - filterWhereClause, warnings, err := b.compiler.Compile(ctx, query.Filter.Expression) + filterWhereClause, warnings, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{ + FieldMapper: b.fm, + ConditionBuilder: b.cb, + FieldKeys: keys, + SkipResourceFilter: true, + }) if err != nil { return nil, err @@ -368,36 +424,14 @@ func (b *logQueryStatementBuilder) addFilterCondition(ctx context.Context, sb *s } // add time filter - startBucket := start/1000000000 - 1800 - endBucket := end / 1000000000 + startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment + endBucket := end / querybuilder.NsToSeconds - sb.Where(sb.GE("timestamp", start), sb.LE("timestamp", end), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket)) + sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.LE("timestamp", fmt.Sprintf("%d", end)), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket)) return warnings, nil } -// combineCTEs takes any number of individual CTE fragments like -// -// "__resource_filter AS (...)", "__limit_cte AS (...)" -// -// and renders the final `WITH …` clause. -func combineCTEs(ctes []string) string { - if len(ctes) == 0 { - return "" - } - return "WITH " + strings.Join(ctes, ", ") + " " -} - -// prependArgs ensures CTE arguments appear before main-query arguments -// in the final slice so their ordinal positions match the SQL string. -func prependArgs(cteArgs [][]any, mainArgs []any) []any { - out := make([]any, 0, len(mainArgs)+len(cteArgs)) - for _, a := range cteArgs { // CTEs first, in declaration order - out = append(out, a...) - } - return append(out, mainArgs...) -} - func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) (int, bool) { for i, agg := range q.Aggregations { if k.Key.Name == agg.Alias || @@ -432,7 +466,7 @@ func (b *logQueryStatementBuilder) buildResourceFilterCTE( start, end uint64, ) (*qbtypes.Statement, error) { - return b.opts.ResourceFilterStmtBuilder.Build( + return b.resourceFilterStmtBuilder.Build( ctx, start, end, diff --git a/pkg/telemetrylogs/stmt_builder_test.go b/pkg/telemetrylogs/stmt_builder_test.go index 418b2ce52917..11ec03a397fe 100644 --- a/pkg/telemetrylogs/stmt_builder_test.go +++ b/pkg/telemetrylogs/stmt_builder_test.go @@ -2,6 +2,7 @@ package telemetrylogs import ( "context" + "log/slog" "testing" "time" @@ -17,18 +18,19 @@ func resourceFilterStmtBuilder() (qbtypes.StatementBuilder[qbtypes.LogAggregatio fm := resourcefilter.NewFieldMapper() cb := resourcefilter.NewConditionBuilder(fm) mockMetadataStore := telemetrytypestest.NewMockMetadataStore() - mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() - compiler := resourcefilter.NewFilterCompiler(resourcefilter.FilterCompilerOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - MetadataStore: mockMetadataStore, - }) + keysMap := buildCompleteFieldKeyMap() + for _, keys := range keysMap { + for _, key := range keys { + key.Signal = telemetrytypes.SignalLogs + } + } + mockMetadataStore.KeysMap = keysMap - return resourcefilter.NewLogResourceFilterStatementBuilder(resourcefilter.ResourceFilterStatementBuilderOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - Compiler: compiler, - }), nil + return resourcefilter.NewLogResourceFilterStatementBuilder( + fm, + cb, + mockMetadataStore, + ), nil } func TestStatementBuilder(t *testing.T) { @@ -63,8 +65,8 @@ func TestStatementBuilder(t *testing.T) { }, }, expected: qbtypes.Statement{ - Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ALL ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) IN (SELECT `service.name` FROM __limit_cte) GROUP BY ALL", - Args: []any{"cartservice", "%service.name%", "%service.name%cartservice%", uint64(1747945619), uint64(1747983448), uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448), 10, uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448)}, + Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ALL ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) IN (SELECT `service.name` FROM __limit_cte) GROUP BY ALL", + Args: []any{"cartservice", "%service.name%", "%service.name%cartservice%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)}, }, expectedErr: nil, }, @@ -74,29 +76,20 @@ func TestStatementBuilder(t *testing.T) { cb := NewConditionBuilder(fm) mockMetadataStore := telemetrytypestest.NewMockMetadataStore() mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() - compiler := NewFilterCompiler(FilterCompilerOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - MetadataStore: mockMetadataStore, - SkipResourceFilter: true, - }) - aggExprRewriter := querybuilder.NewAggExprRewriter(querybuilder.AggExprRewriterOptions{ - FieldMapper: fm, - ConditionBuilder: cb, - MetadataStore: mockMetadataStore, - }) + + aggExprRewriter := querybuilder.NewAggExprRewriter(nil, fm, cb, "", nil) resourceFilterStmtBuilder, err := resourceFilterStmtBuilder() require.NoError(t, err) - statementBuilder := NewLogQueryStatementBuilder(LogQueryStatementBuilderOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - Compiler: compiler, - MetadataStore: mockMetadataStore, - AggExprRewriter: aggExprRewriter, - ResourceFilterStmtBuilder: resourceFilterStmtBuilder, - }) + statementBuilder := NewLogQueryStatementBuilder( + slog.Default(), + mockMetadataStore, + fm, + cb, + resourceFilterStmtBuilder, + aggExprRewriter, + ) for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/pkg/telemetrylogs/test_data.go b/pkg/telemetrylogs/test_data.go index 3e728f291ea9..f8c9dd0d8794 100644 --- a/pkg/telemetrylogs/test_data.go +++ b/pkg/telemetrylogs/test_data.go @@ -19,7 +19,7 @@ func limitString(s string, maxLen int) string { // Function to build a complete field key map for testing all scenarios func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey { - return map[string][]*telemetrytypes.TelemetryFieldKey{ + keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{ "service.name": { { Name: "service.name", @@ -856,4 +856,11 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey { }, }, } + + for _, keys := range keysMap { + for _, key := range keys { + key.Signal = telemetrytypes.SignalLogs + } + } + return keysMap } diff --git a/pkg/telemetrymetadata/metadata.go b/pkg/telemetrymetadata/metadata.go index 7459ebdb5692..80e195ba9b01 100644 --- a/pkg/telemetrymetadata/metadata.go +++ b/pkg/telemetrymetadata/metadata.go @@ -6,6 +6,7 @@ import ( "log/slog" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/querybuilder" "github.com/SigNoz/signoz/pkg/telemetrystore" qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" "github.com/SigNoz/signoz/pkg/types/telemetrytypes" @@ -36,7 +37,6 @@ type telemetryMetaStore struct { fm qbtypes.FieldMapper conditionBuilder qbtypes.ConditionBuilder - compiler qbtypes.FilterCompiler } func NewTelemetryMetaStore( @@ -563,7 +563,20 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel sb := sqlbuilder.Select("DISTINCT " + selectColumn).From(t.relatedMetadataDBName + "." + t.relatedMetadataTblName) if len(fieldValueSelector.ExistingQuery) != 0 { - whereClause, _, err := t.compiler.Compile(ctx, fieldValueSelector.ExistingQuery) + keySelectors := querybuilder.QueryStringToKeysSelectors(fieldValueSelector.ExistingQuery) + for _, keySelector := range keySelectors { + keySelector.Signal = fieldValueSelector.Signal + } + keys, err := t.GetKeysMulti(ctx, keySelectors) + if err != nil { + return nil, err + } + + whereClause, _, err := querybuilder.PrepareWhereClause(fieldValueSelector.ExistingQuery, querybuilder.FilterExprVisitorOpts{ + FieldMapper: t.fm, + ConditionBuilder: t.conditionBuilder, + FieldKeys: keys, + }) if err == nil { sb.AddWhereClause(whereClause) } else { diff --git a/pkg/telemetrytraces/condition_builder.go b/pkg/telemetrytraces/condition_builder.go index eacb3d24aeb6..ebe1e6d1f125 100644 --- a/pkg/telemetrytraces/condition_builder.go +++ b/pkg/telemetrytraces/condition_builder.go @@ -126,6 +126,11 @@ func (c *conditionBuilder) conditionFor( // in the query builder, `exists` and `not exists` are used for // key membership checks, so depending on the column type, the condition changes case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists: + // if the field is intrinsic, it always exists + if slices.Contains(IntrinsicFields, tblFieldName) || slices.Contains(CalculatedFields, tblFieldName) { + return "true", nil + } + var value any switch column.Type { case schema.ColumnTypeString, diff --git a/pkg/telemetrytraces/field_mapper.go b/pkg/telemetrytraces/field_mapper.go index e7630ce9c2d4..7229f9b02ac1 100644 --- a/pkg/telemetrytraces/field_mapper.go +++ b/pkg/telemetrytraces/field_mapper.go @@ -149,7 +149,7 @@ func (m *defaultFieldMapper) getColumn( case telemetrytypes.FieldDataTypeBool: return indexV3Columns["attributes_bool"], nil } - case telemetrytypes.FieldContextSpan: + case telemetrytypes.FieldContextSpan, telemetrytypes.FieldContextUnspecified: if col, ok := indexV3Columns[key.Name]; ok { return col, nil } diff --git a/pkg/telemetrytraces/filter_compiler.go b/pkg/telemetrytraces/filter_compiler.go deleted file mode 100644 index a91e2312647d..000000000000 --- a/pkg/telemetrytraces/filter_compiler.go +++ /dev/null @@ -1,55 +0,0 @@ -package telemetrytraces - -import ( - "context" - - "github.com/SigNoz/signoz/pkg/querybuilder" - qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5" - "github.com/SigNoz/signoz/pkg/types/telemetrytypes" - "github.com/huandu/go-sqlbuilder" -) - -type FilterCompilerOpts struct { - FieldMapper qbtypes.FieldMapper - ConditionBuilder qbtypes.ConditionBuilder - MetadataStore telemetrytypes.MetadataStore - FullTextColumn *telemetrytypes.TelemetryFieldKey - JsonBodyPrefix string - JsonKeyToKey qbtypes.JsonKeyToFieldFunc - SkipResourceFilter bool -} - -type filterCompiler struct { - opts FilterCompilerOpts -} - -func NewFilterCompiler(opts FilterCompilerOpts) *filterCompiler { - return &filterCompiler{ - opts: opts, - } -} - -func (c *filterCompiler) Compile(ctx context.Context, expr string) (*sqlbuilder.WhereClause, []string, error) { - selectors := querybuilder.QueryStringToKeysSelectors(expr) - - keys, err := c.opts.MetadataStore.GetKeysMulti(ctx, selectors) - if err != nil { - return nil, nil, err - } - - filterWhereClause, warnings, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{ - FieldMapper: c.opts.FieldMapper, - ConditionBuilder: c.opts.ConditionBuilder, - FieldKeys: keys, - FullTextColumn: c.opts.FullTextColumn, - JsonBodyPrefix: c.opts.JsonBodyPrefix, - JsonKeyToKey: c.opts.JsonKeyToKey, - SkipResourceFilter: c.opts.SkipResourceFilter, - }) - - if err != nil { - return nil, nil, err - } - - return filterWhereClause, warnings, nil -} diff --git a/pkg/telemetrytraces/statement_builder.go b/pkg/telemetrytraces/statement_builder.go index c6313f820c1c..0d7d05cbe26a 100644 --- a/pkg/telemetrytraces/statement_builder.go +++ b/pkg/telemetrytraces/statement_builder.go @@ -3,6 +3,7 @@ package telemetrytraces import ( "context" "fmt" + "log/slog" "strings" "github.com/SigNoz/signoz/pkg/errors" @@ -16,32 +17,32 @@ var ( ErrUnsupportedAggregation = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported aggregation") ) -type TraceQueryStatementBuilderOpts struct { - MetadataStore telemetrytypes.MetadataStore - FieldMapper qbtypes.FieldMapper - ConditionBuilder qbtypes.ConditionBuilder - ResourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation] - Compiler qbtypes.FilterCompiler - AggExprRewriter qbtypes.AggExprRewriter -} - type traceQueryStatementBuilder struct { - opts TraceQueryStatementBuilderOpts - fm qbtypes.FieldMapper - cb qbtypes.ConditionBuilder - compiler qbtypes.FilterCompiler - aggExprRewriter qbtypes.AggExprRewriter + logger *slog.Logger + metadataStore telemetrytypes.MetadataStore + fm qbtypes.FieldMapper + cb qbtypes.ConditionBuilder + resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation] + aggExprRewriter qbtypes.AggExprRewriter } var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil) -func NewTraceQueryStatementBuilder(opts TraceQueryStatementBuilderOpts) *traceQueryStatementBuilder { +func NewTraceQueryStatementBuilder( + logger *slog.Logger, + metadataStore telemetrytypes.MetadataStore, + fieldMapper qbtypes.FieldMapper, + conditionBuilder qbtypes.ConditionBuilder, + resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], + aggExprRewriter qbtypes.AggExprRewriter, +) *traceQueryStatementBuilder { return &traceQueryStatementBuilder{ - opts: opts, - fm: opts.FieldMapper, - cb: opts.ConditionBuilder, - compiler: opts.Compiler, - aggExprRewriter: opts.AggExprRewriter, + logger: logger, + metadataStore: metadataStore, + fm: fieldMapper, + cb: conditionBuilder, + resourceFilterStmtBuilder: resourceFilterStmtBuilder, + aggExprRewriter: aggExprRewriter, } } @@ -58,13 +59,14 @@ func (b *traceQueryStatementBuilder) Build( end = querybuilder.ToNanoSecs(end) keySelectors := getKeySelectors(query) - keys, err := b.opts.MetadataStore.GetKeysMulti(ctx, keySelectors) + + keys, err := b.metadataStore.GetKeysMulti(ctx, keySelectors) if err != nil { return nil, err } // Create SQL builder - q := sqlbuilder.ClickHouse.NewSelectBuilder() + q := sqlbuilder.NewSelectBuilder() switch requestType { case qbtypes.RequestTypeRaw: @@ -87,8 +89,38 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) keySelectors = append(keySelectors, selectors...) } - whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) - keySelectors = append(keySelectors, whereClauseSelectors...) + if query.Filter != nil && query.Filter.Expression != "" { + whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(query.Filter.Expression) + keySelectors = append(keySelectors, whereClauseSelectors...) + } + + for idx := range query.GroupBy { + groupBy := query.GroupBy[idx] + selectors := querybuilder.QueryStringToKeysSelectors(groupBy.TelemetryFieldKey.Name) + keySelectors = append(keySelectors, selectors...) + } + + for idx := range query.SelectFields { + keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{ + Name: query.SelectFields[idx].Name, + Signal: telemetrytypes.SignalTraces, + FieldContext: query.SelectFields[idx].FieldContext, + FieldDataType: query.SelectFields[idx].FieldDataType, + }) + } + + for idx := range query.Order { + keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{ + Name: query.Order[idx].Key.Name, + Signal: telemetrytypes.SignalTraces, + FieldContext: query.Order[idx].Key.FieldContext, + FieldDataType: query.Order[idx].Key.FieldDataType, + }) + } + + for idx := range keySelectors { + keySelectors[idx].Signal = telemetrytypes.SignalTraces + } return keySelectors } @@ -120,31 +152,32 @@ func (b *traceQueryStatementBuilder) buildListQuery( "trace_id", "span_id", "name", - "resource_string_service$$name", + sqlbuilder.Escape("resource_string_service$$name"), "duration_nano", "response_status_code", ) + // TODO: should we deprecate `SelectFields` and return everything from a span like we do for logs? for _, field := range query.SelectFields { colExpr, err := b.fm.ColumnExpressionFor(ctx, &field, keys) if err != nil { return nil, err } - sb.SelectMore(colExpr) + sb.SelectMore(sqlbuilder.Escape(colExpr)) } // From table sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName)) // Add filter conditions - warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys) if err != nil { return nil, err } // Add order by for _, orderBy := range query.Order { - sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction)) + sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue())) } // Add limit and offset @@ -158,8 +191,8 @@ func (b *traceQueryStatementBuilder) buildListQuery( mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse) - finalSQL := combineCTEs(cteFragments) + mainSQL - finalArgs := prependArgs(cteArgs, mainArgs) + finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs) return &qbtypes.Statement{ Query: finalSQL, @@ -193,21 +226,29 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery( int64(query.StepInterval.Seconds()), )) + var allGroupByArgs []any + // Keep original column expressions so we can build the tuple fieldNames := make([]string, 0, len(query.GroupBy)) for _, gb := range query.GroupBy { - colExpr, err := b.fm.ColumnExpressionFor(ctx, &gb.TelemetryFieldKey, keys) + expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString) if err != nil { return nil, err } - sb.SelectMore(colExpr) + colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.TelemetryFieldKey.Name) + allGroupByArgs = append(allGroupByArgs, args...) + sb.SelectMore(sqlbuilder.Escape(colExpr)) fieldNames = append(fieldNames, fmt.Sprintf("`%s`", gb.TelemetryFieldKey.Name)) } // Aggregations allAggChArgs := make([]any, 0) for i, agg := range query.Aggregations { - rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, agg.Expression) + rewritten, chArgs, err := b.aggExprRewriter.Rewrite( + ctx, agg.Expression, + uint64(query.StepInterval.Seconds()), + keys, + ) if err != nil { return nil, err } @@ -216,7 +257,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery( } sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName)) - warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys) if err != nil { return nil, err } @@ -226,7 +267,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery( if query.Limit > 0 { // build the scalar “top/bottom-N” query in its own builder. - cteSB := sqlbuilder.ClickHouse.NewSelectBuilder() + cteSB := sqlbuilder.NewSelectBuilder() cteStmt, err := b.buildScalarQuery(ctx, cteSB, query, start, end, keys, true) if err != nil { return nil, err @@ -245,11 +286,12 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery( sb.Having(query.Having.Expression) } - mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + combinedArgs := append(allGroupByArgs, allAggChArgs...) + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...) // Stitch it all together: WITH … SELECT … - finalSQL = combineCTEs(cteFragments) + mainSQL - finalArgs = prependArgs(cteArgs, mainArgs) + finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs) } else { sb.GroupBy("ALL") @@ -257,11 +299,12 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery( sb.Having(query.Having.Expression) } - mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + combinedArgs := append(allGroupByArgs, allAggChArgs...) + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...) // Stitch it all together: WITH … SELECT … - finalSQL = combineCTEs(cteFragments) + mainSQL - finalArgs = prependArgs(cteArgs, mainArgs) + finalSQL = querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs) } return &qbtypes.Statement{ @@ -295,20 +338,29 @@ func (b *traceQueryStatementBuilder) buildScalarQuery( allAggChArgs := []any{} - // Add group by columns - for _, groupBy := range query.GroupBy { - colExpr, err := b.fm.ColumnExpressionFor(ctx, &groupBy.TelemetryFieldKey, keys) + var allGroupByArgs []any + for _, gb := range query.GroupBy { + expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString) if err != nil { return nil, err } - sb.SelectMore(colExpr) + colExpr := fmt.Sprintf("toString(%s) AS `%s`", expr, gb.TelemetryFieldKey.Name) + allGroupByArgs = append(allGroupByArgs, args...) + sb.SelectMore(sqlbuilder.Escape(colExpr)) } + // for scalar queries, the rate would be end-start + rateInterval := (end - start) / querybuilder.NsToSeconds + // Add aggregation if len(query.Aggregations) > 0 { for idx := range query.Aggregations { aggExpr := query.Aggregations[idx] - rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, aggExpr.Expression) + rewritten, chArgs, err := b.aggExprRewriter.Rewrite( + ctx, aggExpr.Expression, + rateInterval, + keys, + ) if err != nil { return nil, err } @@ -321,7 +373,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery( sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName)) // Add filter conditions - warnings, err := b.addFilterCondition(ctx, sb, start, end, query) + warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys) if err != nil { return nil, err } @@ -338,9 +390,9 @@ func (b *traceQueryStatementBuilder) buildScalarQuery( for _, orderBy := range query.Order { idx, ok := aggOrderBy(orderBy, query) if ok { - sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction)) + sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue())) } else { - sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction)) + sb.OrderBy(fmt.Sprintf("`%s` %s", orderBy.Key.Name, orderBy.Direction.StringValue())) } } @@ -354,10 +406,12 @@ func (b *traceQueryStatementBuilder) buildScalarQuery( sb.Limit(query.Limit) } - mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, allAggChArgs...) + combinedArgs := append(allGroupByArgs, allAggChArgs...) - finalSQL := combineCTEs(cteFragments) + mainSQL - finalArgs := prependArgs(cteArgs, mainArgs) + mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...) + + finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs) return &qbtypes.Statement{ Query: finalSQL, @@ -367,14 +421,30 @@ func (b *traceQueryStatementBuilder) buildScalarQuery( } // buildFilterCondition builds SQL condition from filter expression -func (b *traceQueryStatementBuilder) addFilterCondition(ctx context.Context, sb *sqlbuilder.SelectBuilder, start, end uint64, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) ([]string, error) { +func (b *traceQueryStatementBuilder) addFilterCondition( + _ context.Context, + sb *sqlbuilder.SelectBuilder, + start, end uint64, + query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], + keys map[string][]*telemetrytypes.TelemetryFieldKey, +) ([]string, error) { - // add filter expression + var filterWhereClause *sqlbuilder.WhereClause + var warnings []string + var err error - filterWhereClause, warnings, err := b.compiler.Compile(ctx, query.Filter.Expression) + if query.Filter != nil && query.Filter.Expression != "" { + // add filter expression + filterWhereClause, warnings, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{ + FieldMapper: b.fm, + ConditionBuilder: b.cb, + FieldKeys: keys, + SkipResourceFilter: true, + }) - if err != nil { - return nil, err + if err != nil { + return nil, err + } } if filterWhereClause != nil { @@ -382,36 +452,14 @@ func (b *traceQueryStatementBuilder) addFilterCondition(ctx context.Context, sb } // add time filter - startBucket := start/1000000000 - 1800 - endBucket := end / 1000000000 + startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment + endBucket := end / querybuilder.NsToSeconds - sb.Where(sb.GE("timestamp", start), sb.LE("timestamp", end), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket)) + sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.LE("timestamp", fmt.Sprintf("%d", end)), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket)) return warnings, nil } -// combineCTEs takes any number of individual CTE fragments like -// -// "__resource_filter AS (...)", "__limit_cte AS (...)" -// -// and renders the final `WITH …` clause. -func combineCTEs(ctes []string) string { - if len(ctes) == 0 { - return "" - } - return "WITH " + strings.Join(ctes, ", ") + " " -} - -// prependArgs ensures CTE arguments appear before main-query arguments -// in the final slice so their ordinal positions match the SQL string. -func prependArgs(cteArgs [][]any, mainArgs []any) []any { - out := make([]any, 0, len(mainArgs)+len(cteArgs)) - for _, a := range cteArgs { // CTEs first, in declaration order - out = append(out, a...) - } - return append(out, mainArgs...) -} - func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) { for i, agg := range q.Aggregations { if k.Key.Name == agg.Alias || @@ -446,7 +494,7 @@ func (b *traceQueryStatementBuilder) buildResourceFilterCTE( start, end uint64, ) (*qbtypes.Statement, error) { - return b.opts.ResourceFilterStmtBuilder.Build( + return b.resourceFilterStmtBuilder.Build( ctx, start, end, diff --git a/pkg/telemetrytraces/stmt_builder_test.go b/pkg/telemetrytraces/stmt_builder_test.go index 864838cf301a..84484723820c 100644 --- a/pkg/telemetrytraces/stmt_builder_test.go +++ b/pkg/telemetrytraces/stmt_builder_test.go @@ -2,6 +2,7 @@ package telemetrytraces import ( "context" + "log/slog" "testing" "time" @@ -18,17 +19,12 @@ func resourceFilterStmtBuilder() (qbtypes.StatementBuilder[qbtypes.TraceAggregat cb := resourcefilter.NewConditionBuilder(fm) mockMetadataStore := telemetrytypestest.NewMockMetadataStore() mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() - compiler := resourcefilter.NewFilterCompiler(resourcefilter.FilterCompilerOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - MetadataStore: mockMetadataStore, - }) - return resourcefilter.NewTraceResourceFilterStatementBuilder(resourcefilter.ResourceFilterStatementBuilderOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - Compiler: compiler, - }), nil + return resourcefilter.NewTraceResourceFilterStatementBuilder( + fm, + cb, + mockMetadataStore, + ), nil } func TestStatementBuilder(t *testing.T) { @@ -63,8 +59,8 @@ func TestStatementBuilder(t *testing.T) { }, }, expected: qbtypes.Statement{ - Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ALL ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, resources_string['service.name'] AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) IN (SELECT `service.name` FROM __limit_cte) GROUP BY ALL", - Args: []any{"redis-manual", "%service.name%", "%service.name%redis-manual%", uint64(1747945619), uint64(1747983448), uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448), 10, uint64(1747947419000000000), uint64(1747983448000000000), uint64(1747945619), uint64(1747983448)}, + Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY ALL ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp <= ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) IN (SELECT `service.name` FROM __limit_cte) GROUP BY ALL", + Args: []any{"redis-manual", "%service.name%", "%service.name%redis-manual%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)}, }, expectedErr: nil, }, @@ -74,29 +70,19 @@ func TestStatementBuilder(t *testing.T) { cb := NewConditionBuilder(fm) mockMetadataStore := telemetrytypestest.NewMockMetadataStore() mockMetadataStore.KeysMap = buildCompleteFieldKeyMap() - compiler := NewFilterCompiler(FilterCompilerOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - MetadataStore: mockMetadataStore, - SkipResourceFilter: true, - }) - aggExprRewriter := querybuilder.NewAggExprRewriter(querybuilder.AggExprRewriterOptions{ - FieldMapper: fm, - ConditionBuilder: cb, - MetadataStore: mockMetadataStore, - }) + aggExprRewriter := querybuilder.NewAggExprRewriter(nil, fm, cb, "", nil) resourceFilterStmtBuilder, err := resourceFilterStmtBuilder() require.NoError(t, err) - statementBuilder := NewTraceQueryStatementBuilder(TraceQueryStatementBuilderOpts{ - FieldMapper: fm, - ConditionBuilder: cb, - Compiler: compiler, - MetadataStore: mockMetadataStore, - AggExprRewriter: aggExprRewriter, - ResourceFilterStmtBuilder: resourceFilterStmtBuilder, - }) + statementBuilder := NewTraceQueryStatementBuilder( + slog.Default(), + mockMetadataStore, + fm, + cb, + resourceFilterStmtBuilder, + aggExprRewriter, + ) for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/pkg/telemetrytraces/test_data.go b/pkg/telemetrytraces/test_data.go index 051af18cc712..926fc61aae76 100644 --- a/pkg/telemetrytraces/test_data.go +++ b/pkg/telemetrytraces/test_data.go @@ -5,7 +5,7 @@ import ( ) func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey { - return map[string][]*telemetrytypes.TelemetryFieldKey{ + keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{ "service.name": { { Name: "service.name", @@ -35,4 +35,10 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey { }, }, } + for _, keys := range keysMap { + for _, key := range keys { + key.Signal = telemetrytypes.SignalTraces + } + } + return keysMap } diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/qb.go b/pkg/types/querybuildertypes/querybuildertypesv5/qb.go index a4706b0ede8d..bc0f7ce071c2 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/qb.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/qb.go @@ -34,24 +34,10 @@ type ConditionBuilder interface { ConditionFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey, operator FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) } -type FilterCompiler interface { - // Compile compiles the filter into a sqlbuilder.WhereClause. - Compile(ctx context.Context, filter string) (*sqlbuilder.WhereClause, []string, error) -} - -type RewriteCtx struct { - RateInterval uint64 -} - -type RewriteOption func(*RewriteCtx) - -func WithRateInterval(interval uint64) RewriteOption { - return func(c *RewriteCtx) { c.RateInterval = interval } -} - type AggExprRewriter interface { // Rewrite rewrites the aggregation expression to be used in the query. - Rewrite(ctx context.Context, expr string, opts ...RewriteOption) (string, []any, error) + Rewrite(ctx context.Context, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error) + RewriteMulti(ctx context.Context, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error) } type Statement struct { diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/query.go b/pkg/types/querybuildertypes/querybuildertypesv5/query.go index 070fd5dfa2de..d56e7b0fa646 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/query.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/query.go @@ -11,19 +11,20 @@ type Query interface { // Window returns [from, to) in epoch‑ms so cache can slice/merge. Window() (startMS, endMS uint64) // Execute runs the query; implementors must be side‑effect‑free. - Execute(ctx context.Context) (Result, error) + Execute(ctx context.Context) (*Result, error) } type Result struct { - Type RequestType - Value any // concrete Go value (to be type asserted based on the RequestType) - Stats ExecStats + Type RequestType + Value any // concrete Go value (to be type asserted based on the RequestType) + Stats ExecStats + Warnings []string } type ExecStats struct { - RowsScanned int64 `json:"rowsScanned"` - BytesScanned int64 `json:"bytesScanned"` - DurationMS int64 `json:"durationMs"` + RowsScanned uint64 `json:"rowsScanned"` + BytesScanned uint64 `json:"bytesScanned"` + DurationMS uint64 `json:"durationMs"` } type TimeRange struct{ From, To uint64 } // ms since epoch diff --git a/pkg/types/querybuildertypes/querybuildertypesv5/resp.go b/pkg/types/querybuildertypes/querybuildertypesv5/resp.go index 1e9bc69aaccc..bde7722939dd 100644 --- a/pkg/types/querybuildertypes/querybuildertypesv5/resp.go +++ b/pkg/types/querybuildertypes/querybuildertypesv5/resp.go @@ -14,19 +14,19 @@ type QueryRangeResponse struct { } type TimeSeriesData struct { - QueryName string `json:"queryName"` - Aggregations []AggregationBucket `json:"aggregations"` + QueryName string `json:"queryName"` + Aggregations []*AggregationBucket `json:"aggregations"` } type AggregationBucket struct { - Index int `json:"index"` // or string Alias - Alias string `json:"alias"` - Series []TimeSeries `json:"series"` // no extra nesting + Index int `json:"index"` // or string Alias + Alias string `json:"alias"` + Series []*TimeSeries `json:"series"` // no extra nesting } type TimeSeries struct { - Labels []Label `json:"labels,omitempty"` - Values []TimeSeriesValue `json:"values"` + Labels []*Label `json:"labels,omitempty"` + Values []*TimeSeriesValue `json:"values"` } type Label struct { @@ -36,10 +36,10 @@ type Label struct { type TimeSeriesValue struct { Timestamp int64 `json:"timestamp"` - Value float64 `json:"value,omitempty"` + Value float64 `json:"value"` // for the heatmap type chart Values []float64 `json:"values,omitempty"` - Bucket Bucket `json:"bucket,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` } type Bucket struct { @@ -65,16 +65,17 @@ type ColumnDescriptor struct { } type ScalarData struct { - Columns []ColumnDescriptor `json:"columns"` - Data [][]any `json:"data"` + Columns []*ColumnDescriptor `json:"columns"` + Data [][]any `json:"data"` } type RawData struct { - QueryName string `json:"queryName"` - Rows []RawRow `json:"rows"` + QueryName string `json:"queryName"` + NextCursor string `json:"nextCursor"` + Rows []*RawRow `json:"rows"` } type RawRow struct { - Timestamp time.Time `json:"timestamp"` - Data map[string]any `json:"data"` + Timestamp time.Time `json:"timestamp"` + Data map[string]*any `json:"data"` } From ae7364f09898aea4b3d8310d3b7fdecbb02bb558 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Tue, 27 May 2025 21:17:33 +0530 Subject: [PATCH 22/24] fix(login): fixed the interceptor to handle multiple failures (#8071) * fix(login): fixed the interceptor to handle multiple failures * fix(login): fixed the interceptor to handle multiple failures --- frontend/src/api/index.ts | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f58e2405163e..a5e62ae78942 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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,24 +87,27 @@ const interceptorRejected = async ( true, ); - const reResponse = await axios( - `${value.config.baseURL}${value.config.url?.substring(1)}`, - { - method: value.config.method, - headers: { - ...value.config.headers, - Authorization: `Bearer ${response.data.accessJwt}`, + try { + const reResponse = await axios( + `${value.config.baseURL}${value.config.url?.substring(1)}`, + { + method: value.config.method, + headers: { + ...value.config.headers, + Authorization: `Bearer ${response.data.accessJwt}`, + }, + data: { + ...JSON.parse(value.config.data || '{}'), + }, }, - data: { - ...JSON.parse(value.config.data || '{}'), - }, - }, - ); - if (reResponse.status === 200) { + ); + return await Promise.resolve(reResponse); + } catch (error) { + if ((error as AxiosError)?.response?.status === 401) { + Logout(); + } } - Logout(); - return await Promise.reject(reResponse); } catch (error) { Logout(); } From 83b8eaf623e35c834ea819265a29bb4e246c1f2d Mon Sep 17 00:00:00 2001 From: Vibhu Pandey Date: Tue, 27 May 2025 23:02:45 +0530 Subject: [PATCH 23/24] feat(pylon|appcues): add pylon and appcues (#8073) --- .github/workflows/build-enterprise.yaml | 5 ++--- .github/workflows/build-staging.yaml | 3 ++- .github/workflows/gor-signoz.yaml | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-enterprise.yaml b/.github/workflows/build-enterprise.yaml index 4031abed106a..99b80bafa700 100644 --- a/.github/workflows/build-enterprise.yaml +++ b/.github/workflows/build-enterprise.yaml @@ -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: diff --git a/.github/workflows/build-staging.yaml b/.github/workflows/build-staging.yaml index 0effc134928b..9006f0233955 100644 --- a/.github/workflows/build-staging.yaml +++ b/.github/workflows/build-staging.yaml @@ -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: diff --git a/.github/workflows/gor-signoz.yaml b/.github/workflows/gor-signoz.yaml index a74f5aa92dae..4f8f923fe834 100644 --- a/.github/workflows/gor-signoz.yaml +++ b/.github/workflows/gor-signoz.yaml @@ -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 From d732f8ba425a17d063197ee626d52572125d81dd Mon Sep 17 00:00:00 2001 From: Ekansh Gupta Date: Tue, 27 May 2025 23:12:08 +0530 Subject: [PATCH 24/24] fix: updated the service name in exceptions filter (#8069) * fix: updated the service name in exceptions filter * fix: updated the service name in exceptions filter * fix: updated the service name in exceptions filter --- pkg/query-service/utils/testutils.go | 1 + pkg/signoz/provider.go | 1 + .../035_update_api_monitoring_filters.go | 103 ++++++++++++++++++ pkg/types/quickfiltertypes/filter.go | 2 +- 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 pkg/sqlmigration/035_update_api_monitoring_filters.go diff --git a/pkg/query-service/utils/testutils.go b/pkg/query-service/utils/testutils.go index b7cf1fbcbca8..5cd7960786fc 100644 --- a/pkg/query-service/utils/testutils.go +++ b/pkg/query-service/utils/testutils.go @@ -66,6 +66,7 @@ func NewTestSqliteDB(t *testing.T) (sqlStore sqlstore.SQLStore, testDBFilePath s sqlmigration.NewUpdateQuickFiltersFactory(sqlStore), sqlmigration.NewAuthRefactorFactory(sqlStore), sqlmigration.NewMigratePATToFactorAPIKey(sqlStore), + sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlStore), ), ) if err != nil { diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index d5363edd680c..831d2e2a62a3 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -82,6 +82,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM sqlmigration.NewAuthRefactorFactory(sqlstore), sqlmigration.NewUpdateLicenseFactory(sqlstore), sqlmigration.NewMigratePATToFactorAPIKey(sqlstore), + sqlmigration.NewUpdateApiMonitoringFiltersFactory(sqlstore), ) } diff --git a/pkg/sqlmigration/035_update_api_monitoring_filters.go b/pkg/sqlmigration/035_update_api_monitoring_filters.go new file mode 100644 index 000000000000..a1efc6076677 --- /dev/null +++ b/pkg/sqlmigration/035_update_api_monitoring_filters.go @@ -0,0 +1,103 @@ +package sqlmigration + +import ( + "context" + "database/sql" + "time" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +type updateApiMonitoringFilters struct { + store sqlstore.SQLStore +} + +func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return newUpdateApiMonitoringFilters(ctx, ps, c, store) + }) +} + +func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) { + return &updateApiMonitoringFilters{ + store: store, + }, nil +} + +func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error { + if err := migrations.Register(migration.Up, migration.Down); err != nil { + return err + } + + return nil +} + +func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer func() { + _ = tx.Rollback() + }() + + // Get all organization IDs as strings + var orgIDs []string + err = tx.NewSelect(). + Table("organizations"). + Column("id"). + Scan(ctx, &orgIDs) + if err != nil { + if err == sql.ErrNoRows { + if err := tx.Commit(); err != nil { + return err + } + return nil + } + return err + } + + for _, orgID := range orgIDs { + // Get the updated default quick filters which includes the new API monitoring filters + storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID)) + if err != nil { + return err + } + + // Find the API monitoring filter from the storable quick filters + var apiMonitoringFilterJSON string + for _, filter := range storableQuickFilters { + if filter.Signal == quickfiltertypes.SignalApiMonitoring { + apiMonitoringFilterJSON = filter.Filter + break + } + } + + if apiMonitoringFilterJSON != "" { + _, err = tx.NewUpdate(). + Table("quick_filter"). + Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()). + Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID). + Exec(ctx) + + if err != nil { + return err + } + } + } + + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func (migration *updateApiMonitoringFilters) Down(ctx context.Context, db *bun.DB) error { + return nil +} diff --git a/pkg/types/quickfiltertypes/filter.go b/pkg/types/quickfiltertypes/filter.go index 9d00833c7eae..ac436a451b3b 100644 --- a/pkg/types/quickfiltertypes/filter.go +++ b/pkg/types/quickfiltertypes/filter.go @@ -164,7 +164,7 @@ func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) { apiMonitoringFilters := []map[string]interface{}{ {"key": "deployment.environment", "dataType": "string", "type": "resource"}, - {"key": "service.name", "dataType": "string", "type": "tag"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, {"key": "rpc.method", "dataType": "string", "type": "tag"}, }