Compare commits

...

31 Commits

Author SHA1 Message Date
swapnil-signoz
ce54f2f277 Merge branch 'refactor/cloud-integration-types' into feat/cloudintegration-module-skeleton 2026-03-16 17:35:55 +05:30
swapnil-signoz
4b94287ac7 refactor: add comments for backward compatibility in PostableAgentCheckInRequest 2026-03-16 15:48:20 +05:30
swapnil-signoz
1575c7c54c refactor: streamlining types 2026-03-16 15:39:32 +05:30
swapnil-signoz
8def3f835b refactor: adding comments and removed wrong code 2026-03-16 11:10:53 +05:30
swapnil-signoz
9e468513c5 feat: adding module skeleton 2026-03-16 10:37:33 +05:30
swapnil-signoz
05062d49e9 feat: adding module skeleton 2026-03-14 19:19:14 +05:30
swapnil-signoz
11ed15f4c5 feat: implement cloud integration store 2026-03-14 17:05:02 +05:30
swapnil-signoz
f47877cca9 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 17:01:51 +05:30
swapnil-signoz
bb2b9215ba fix: correct GetService signature and remove shadowed Data field 2026-03-14 16:59:07 +05:30
swapnil-signoz
3111904223 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-14 16:36:35 +05:30
swapnil-signoz
003e2c30d8 Merge branch 'main' into refactor/cloud-integration-types 2026-03-14 16:25:35 +05:30
swapnil-signoz
00fe516d10 refactor: update cloud integration types and module interface 2026-03-14 16:25:16 +05:30
Abhi kumar
c5ef455283 fix: added fix for panel setting scrollbar issue (#10587)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* fix: added fix for panel setting scrollbar issue

* fix: added changes for panel switch
2026-03-13 19:30:49 +00:00
Ishan
2316b5be83 Sig 3634 revert (#10578)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* Revert "Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)"

This reverts commit 5b8d5fbfd3.

* fix: stop bubble
2026-03-13 15:29:28 +00:00
Abhi kumar
937ebc1582 feat: added section in panel settings (#10569)
* feat: added section in panel settings

* chore: minor changes

* fix: fixed failing tests

* fix: minor style fixes

* chore: updated the categorisation

* chore: updated styles

* chore: minor styles improvements

* chore: formatting unit section fix
2026-03-13 13:22:10 +00:00
Ashwin Bhatkal
dcc8173c79 fix: variables initial url state (#10579)
* fix: variables-initial-url-state

* chore: add tests
2026-03-13 11:16:47 +00:00
swapnil-signoz
0305f4f7db refactor: using struct for map 2026-03-13 16:09:26 +05:30
Ashwin Bhatkal
4b4ef5ce58 fix: edit mode variables not persisting value (#10576)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: edit mode variables not persisting value

* chore: move into hook

* chore: add tests

* chore: fix tests

* chore: move functions
2026-03-13 07:49:40 +00:00
Yunus M
5b8d5fbfd3 Revert "feat: Option to zoom out OR reset zoom in the explorer pages (#10464)" (#10574)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This reverts commit 557451ed81.
2026-03-12 19:24:49 +00:00
swapnil-signoz
c60019a6dc Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 23:41:22 +05:30
swapnil-signoz
acde2a37fa feat: adding updated types for cloud integration 2026-03-12 23:40:44 +05:30
swapnil-signoz
945241a52a Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 19:45:50 +05:30
swapnil-signoz
e967f80c86 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 16:39:42 +05:30
swapnil-signoz
a09dc325de Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 16:39:20 +05:30
swapnil-signoz
379b4f7fc4 refactor: removing interface check 2026-03-02 14:50:37 +05:30
swapnil-signoz
5e536ae077 Merge branch 'refactor/cloud-integration-types' into refactor/cloud-integration-impl-store 2026-03-02 14:49:35 +05:30
swapnil-signoz
234585e642 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 14:49:19 +05:30
swapnil-signoz
2cc14f1ad4 Merge branch 'main' into refactor/cloud-integration-impl-store 2026-03-02 14:49:00 +05:30
swapnil-signoz
dc4ed4d239 feat: adding sql store implementation 2026-03-02 14:44:56 +05:30
swapnil-signoz
7281c36873 refactor: store interfaces to use local types and error 2026-03-02 13:27:46 +05:30
swapnil-signoz
40288776e8 feat: adding cloud integration type for refactor 2026-02-28 16:59:14 +05:30
45 changed files with 2561 additions and 578 deletions

5
.github/CODEOWNERS vendored
View File

@@ -127,12 +127,15 @@
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend /frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend /frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Dashboard Page ## Dashboard Page
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend /frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend /frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend /frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Public Dashboard Page ## Public Dashboard Page

View File

@@ -13,8 +13,11 @@ import (
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway" "github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway" "github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing" "github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization"
@@ -88,6 +91,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler { func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
return querier.NewHandler(ps, q, a) return querier.NewHandler(ps, q, a)
}, },
func(_ sqlstore.SQLStore, _ licensing.Licensing, _ zeus.Zeus, _ gateway.Gateway, _ global.Config) cloudintegration.Module {
return implcloudintegration.NewModule()
},
) )
if err != nil { if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err) logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -9,12 +9,13 @@ import (
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn" "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn" "github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz" "github.com/SigNoz/signoz/ee/authz/openfgaauthz"
eequerier "github.com/SigNoz/signoz/ee/querier"
"github.com/SigNoz/signoz/ee/authz/openfgaschema" "github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/gateway/httpgateway" "github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing" enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing" "github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard" "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app" enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema" "github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
@@ -25,7 +26,10 @@ import (
"github.com/SigNoz/signoz/pkg/authz" "github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway" "github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
pkgimplcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard" pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization"
@@ -129,6 +133,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
communityHandler := querier.NewHandler(ps, q, a) communityHandler := querier.NewHandler(ps, q, a)
return eequerier.NewHandler(ps, q, communityHandler) return eequerier.NewHandler(ps, q, communityHandler)
}, },
func(store sqlstore.SQLStore, lic licensing.Licensing, z zeus.Zeus, gw gateway.Gateway, gc global.Config) cloudintegration.Module {
return implcloudintegration.NewModule(pkgimplcloudintegration.NewStore(store), store, lic, z, gw, gc)
},
) )
if err != nil { if err != nil {

View File

@@ -0,0 +1,22 @@
package implcloudintegration
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type awsProvider struct{}
func (p *awsProvider) CreateArtifact(
_ context.Context,
_ valuer.UUID,
_ *cloudintegrationtypes.ConnectionArtifactRequest,
_ cloudintegration.Credentials,
_ valuer.UUID,
) (*cloudintegrationtypes.ConnectionArtifact, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}

View File

@@ -0,0 +1,23 @@
package implcloudintegration
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// keeping this for example to show how more cloud providers will be added
type azureProvider struct{}
func (p *azureProvider) CreateArtifact(
_ context.Context,
_ valuer.UUID,
_ *cloudintegrationtypes.ConnectionArtifactRequest,
_ cloudintegration.Credentials,
_ valuer.UUID,
) (*cloudintegrationtypes.ConnectionArtifact, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}

View File

@@ -0,0 +1,267 @@
package implcloudintegration
import (
"context"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
)
type module struct {
store cloudintegrationtypes.Store
userStore types.UserStore
licensing licensing.Licensing
zeus zeus.Zeus
gateway gateway.Gateway
globalConfig global.Config
providers map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProvider
}
func NewModule(
store cloudintegrationtypes.Store,
sqlStore sqlstore.SQLStore,
lic licensing.Licensing,
z zeus.Zeus,
gw gateway.Gateway,
gc global.Config,
) cloudintegration.Module {
return &module{
store: store,
userStore: impluser.NewStore(sqlStore, factory.ProviderSettings{}),
licensing: lic,
zeus: z,
gateway: gw,
globalConfig: gc,
providers: map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProvider{
cloudintegrationtypes.CloudProviderTypeAWS: &awsProvider{},
cloudintegrationtypes.CloudProviderTypeAzure: &azureProvider{},
},
}
}
func (m *module) CreateConnectionArtifact(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, request *cloudintegrationtypes.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
p, ok := m.providers[provider]
if !ok {
return nil, errors.NewInvalidInputf(cloudintegrationtypes.ErrCodeCloudProviderInvalidInput, "unsupported cloud provider: %s", provider.StringValue())
}
creds, err := m.resolveCredentials(ctx, orgID, provider)
if err != nil {
return nil, err
}
newAccountID := valuer.GenerateUUID()
artifact, err := p.CreateArtifact(ctx, orgID, request, creds, newAccountID)
if err != nil {
return nil, err
}
account := &cloudintegrationtypes.StorableCloudIntegration{
Identifiable: types.Identifiable{ID: newAccountID},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Provider: provider,
OrgID: orgID,
}
if err := m.store.UpsertAccount(ctx, account); err != nil {
return nil, err
}
return artifact, nil
}
func (m *module) resolveCredentials(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (cloudintegration.Credentials, error) {
creds := cloudintegration.Credentials{}
pat, err := m.getOrCreateIntegrationPAT(ctx, orgID, provider)
if err != nil {
return creds, err
}
creds.SigNozAPIKey = pat
if m.licensing == nil {
return creds, nil
}
license, err := m.licensing.GetActive(ctx, orgID)
if err != nil {
return creds, err
}
if license == nil {
return creds, nil
}
respBytes, err := m.zeus.GetDeployment(ctx, license.Key)
if err != nil {
return creds, errors.NewInternalf(errors.CodeInternal, "couldn't query deployment info: %v", err)
}
deployment, err := zeustypes.NewGettableDeployment(respBytes)
if err != nil {
return creds, err
}
creds.SigNozAPIUrl = deployment.SignozAPIUrl
if m.globalConfig.IngestionURL != nil {
creds.IngestionUrl = m.globalConfig.IngestionURL.String()
}
if m.gateway != nil {
ingestionKeyName := fmt.Sprintf("%s-integration", provider.StringValue())
ingestionKey, err := m.getOrCreateIngestionKey(ctx, orgID, ingestionKeyName)
if err != nil {
return creds, err
}
creds.IngestionKey = ingestionKey
}
return creds, nil
}
func (m *module) getOrCreateIngestionKey(ctx context.Context, orgID valuer.UUID, keyName string) (string, error) {
result, err := m.gateway.SearchIngestionKeysByName(ctx, orgID, keyName, 1, 10)
if err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "couldn't search ingestion keys: %v", err)
}
for _, k := range result.Keys {
if k.Name == keyName {
return k.Value, nil
}
}
created, err := m.gateway.CreateIngestionKey(ctx, orgID, keyName, []string{"integration"}, time.Time{})
if err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "couldn't create ingestion key: %v", err)
}
return created.Value, nil
}
func (m *module) getOrCreateIntegrationPAT(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (string, error) {
integrationPATName := fmt.Sprintf("%s integration", provider.StringValue())
integrationUser, err := m.getOrCreateIntegrationUser(ctx, orgID, provider)
if err != nil {
return "", err
}
allPATs, err := m.userStore.ListAPIKeys(ctx, orgID)
if err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "couldn't list PATs: %v", err)
}
for _, p := range allPATs {
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
return p.Token, nil
}
}
newPAT, err := types.NewStorableAPIKey(
integrationPATName,
integrationUser.ID,
types.RoleViewer,
0,
)
if err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "couldn't create cloud integration PAT: %v", err)
}
if err := m.userStore.CreateAPIKey(ctx, newPAT); err != nil {
return "", errors.NewInternalf(errors.CodeInternal, "couldn't persist cloud integration PAT: %v", err)
}
return newPAT.Token, nil
}
func (m *module) getOrCreateIntegrationUser(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*types.User, error) {
cloudIntegrationUserName := fmt.Sprintf("%s-integration", provider.StringValue())
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
existingUsers, err := m.userStore.GetUsersByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "couldn't look up integration user: %v", err)
}
for _, u := range existingUsers {
if u.Status != types.UserStatusDeleted {
return u, nil
}
}
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, orgID, types.UserStatusActive)
if err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "couldn't construct integration user: %v", err)
}
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
if err := m.userStore.CreateUser(ctx, cloudIntegrationUser); err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "couldn't create integration user: %v", err)
}
if err := m.userStore.CreatePassword(ctx, password); err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "couldn't create integration user password: %v", err)
}
return cloudIntegrationUser, nil
}
func (m *module) GetAccountStatus(_ context.Context, _, _ valuer.UUID) (*cloudintegrationtypes.AccountStatus, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) ListConnectedAccounts(_ context.Context, _ valuer.UUID) (*cloudintegrationtypes.ConnectedAccounts, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) DisconnectAccount(_ context.Context, _, _ valuer.UUID) error {
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) UpdateAccountConfig(_ context.Context, _, _ valuer.UUID, _ *cloudintegrationtypes.UpdateAccountConfigRequest) (*cloudintegrationtypes.Account, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) ListServicesSummary(_ context.Context, _ valuer.UUID, _ *valuer.UUID) (*cloudintegrationtypes.ServicesSummary, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) GetService(_ context.Context, _ valuer.UUID, _ string, _ *valuer.UUID) (*cloudintegrationtypes.Service, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) UpdateServiceConfig(_ context.Context, _ string, _ valuer.UUID, _ *cloudintegrationtypes.UpdateServiceConfigRequest) (*cloudintegrationtypes.ServiceSummary, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) AgentCheckIn(_ context.Context, _ valuer.UUID, _ *cloudintegrationtypes.AgentCheckInRequest) (cloudintegrationtypes.AgentCheckInResponse, error) {
return cloudintegrationtypes.AgentCheckInResponse{}, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) GetDashboardByID(_ context.Context, _ string, _ valuer.UUID) (*dashboardtypes.Dashboard, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}
func (m *module) GetAllDashboards(_ context.Context, _ valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not implemented")
}

View File

@@ -297,7 +297,11 @@ function CustomTimePicker({
resetErrorStatus(); resetErrorStatus();
}; };
const handleInputPressEnter = (): void => { const handleInputPressEnter = (
event?: React.KeyboardEvent<HTMLInputElement>,
): void => {
event?.preventDefault();
event?.stopPropagation();
// check if the entered time is in the format of 1m, 2h, 3d, 4w // check if the entered time is in the format of 1m, 2h, 3d, 4w
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue); const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);

View File

@@ -9,7 +9,6 @@ import {
} from 'hooks/dashboard/useDashboardVariables'; } from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl'; import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore'; import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { import {
enqueueDescendantsOfVariable, enqueueDescendantsOfVariable,
@@ -30,7 +29,7 @@ function DashboardVariableSelection(): JSX.Element | null {
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
} = useDashboard(); } = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl(); const { updateUrlVariable } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables(); const { dashboardVariables } = useDashboardVariables();
const dashboardId = useDashboardVariablesSelector( const dashboardId = useDashboardVariablesSelector(
@@ -50,15 +49,6 @@ function DashboardVariableSelection(): JSX.Element | null {
(state) => state.globalTime, (state) => state.globalTime,
); );
useEffect(() => {
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
getUrlVariables,
updateUrlVariable,
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
// Memoize the order key to avoid unnecessary triggers // Memoize the order key to avoid unnecessary triggers
const variableOrderKey = useMemo(() => { const variableOrderKey = useMemo(() => {
const queryVariableOrderKey = dependencyData?.order?.join(',') ?? ''; const queryVariableOrderKey = dependencyData?.order?.join(',') ?? '';

View File

@@ -1,5 +1,7 @@
.column-unit-selector { .column-unit-selector {
margin-top: 16px; display: flex;
flex-direction: column;
gap: 8px;
.heading { .heading {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -30,6 +32,11 @@
width: 100%; width: 100%;
} }
} }
&-content {
display: flex;
flex-direction: column;
gap: 12px;
}
} }
.lightMode { .lightMode {

View File

@@ -72,22 +72,24 @@ export function ColumnUnitSelector(
return ( return (
<section className="column-unit-selector"> <section className="column-unit-selector">
<Typography.Text className="heading">Column Units</Typography.Text> <Typography.Text className="heading">Column Units</Typography.Text>
{aggregationQueries.map(({ value, label }) => { <div className="column-unit-selector-content">
const baseQueryName = value.split('.')[0]; {aggregationQueries.map(({ value, label }) => {
return ( const baseQueryName = value.split('.')[0];
<YAxisUnitSelectorV2 return (
value={columnUnits[value] || ''} <YAxisUnitSelectorV2
onSelect={(unitValue: string): void => value={columnUnits[value] || ''}
handleColumnUnitSelect(value, unitValue) onSelect={(unitValue: string): void =>
} handleColumnUnitSelect(value, unitValue)
fieldLabel={label} }
key={value} fieldLabel={label}
selectedQueryName={baseQueryName} key={value}
// Update the column unit value automatically only in create mode selectedQueryName={baseQueryName}
shouldUpdateYAxisUnit={isNewDashboard} // Update the column unit value automatically only in create mode
/> shouldUpdateYAxisUnit={isNewDashboard}
); />
})} );
})}
</div>
</section> </section>
); );
} }

View File

@@ -56,9 +56,6 @@ describe('ContextLinks Component', () => {
/>, />,
); );
// Check that the component renders
expect(screen.getByText('Context Links')).toBeInTheDocument();
// Check that the add button is present // Check that the add button is present
expect( expect(
screen.getByRole('button', { name: /context link/i }), screen.getByRole('button', { name: /context link/i }),

View File

@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Button, Modal, Typography } from 'antd'; import { Button, Modal } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'; import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react';
import { import {
@@ -134,11 +134,16 @@ function ContextLinks({
return ( return (
<div className="context-links-container"> <div className="context-links-container">
<Typography.Text className="context-links-text">
Context Links
</Typography.Text>
<div className="context-links-list"> <div className="context-links-list">
<Button
type="default"
className="add-context-link-button"
icon={<Plus size={12} />}
style={{ width: '100%' }}
onClick={handleAddContextLink}
>
Add Context Link
</Button>
<OverlayScrollbar> <OverlayScrollbar>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -160,16 +165,6 @@ function ContextLinks({
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</OverlayScrollbar> </OverlayScrollbar>
{/* button to add context link */}
<Button
type="primary"
className="add-context-link-button"
icon={<Plus size={12} />}
onClick={handleAddContextLink}
>
Context Link
</Button>
</div> </div>
<Modal <Modal

View File

@@ -2,7 +2,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
margin: 12px;
} }
.context-links-text { .context-links-text {
@@ -110,10 +109,7 @@
} }
.add-context-link-button { .add-context-link-button {
display: flex; width: 100%;
align-items: center;
margin: auto;
width: fit-content;
} }
.lightMode { .lightMode {

View File

@@ -1,6 +1,7 @@
.right-container { .right-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 48px;
.header { .header {
display: flex; display: flex;
@@ -24,14 +25,14 @@
letter-spacing: -0.07px; letter-spacing: -0.07px;
} }
} }
.control-container {
.name-description {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
border-bottom: 1px solid var(--bg-slate-500);
gap: 8px; gap: 8px;
}
.name-description {
padding: 0 0 4px 0;
.typography { .typography {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -88,9 +89,6 @@
.panel-config { .panel-config {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 12px 12px 16px 12px;
gap: 8px;
border-bottom: 1px solid var(--bg-slate-500);
.typography { .typography {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@@ -104,6 +102,7 @@
} }
.panel-type-select { .panel-type-select {
width: 100%;
.ant-select-selector { .ant-select-selector {
display: flex; display: flex;
height: 32px; height: 32px;
@@ -137,7 +136,6 @@
} }
.fill-gaps { .fill-gaps {
margin-top: 16px;
display: flex; display: flex;
padding: 12px; padding: 12px;
justify-content: space-between; justify-content: space-between;
@@ -156,31 +154,24 @@
letter-spacing: 0.52px; letter-spacing: 0.52px;
text-transform: uppercase; text-transform: uppercase;
} }
.fill-gaps-text-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
opacity: 0.6;
line-height: 16px; /* 133.333% */
}
} }
.log-scale, .log-scale,
.decimal-precision-selector { .decimal-precision-selector,
margin-top: 16px;
display: flex;
justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-position { .legend-position {
margin-top: 16px;
display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: column;
gap: 8px;
}
.legend-colors {
margin-top: 16px;
} }
.panel-time-text { .panel-time-text {
margin-top: 16px;
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
font-size: 13px; font-size: 13px;
@@ -193,7 +184,6 @@
.y-axis-unit-selector, .y-axis-unit-selector,
.y-axis-unit-selector-v2 { .y-axis-unit-selector-v2 {
margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -278,11 +268,8 @@
} }
.stack-chart { .stack-chart {
margin-top: 16px; flex-direction: row;
display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px;
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
@@ -296,11 +283,6 @@
} }
.bucket-config { .bucket-config {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
.label { .label {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
font-family: 'Space Mono'; font-family: 'Space Mono';
@@ -352,16 +334,13 @@
} }
} }
.context-links {
border-bottom: 1px solid var(--bg-slate-500);
}
.alerts { .alerts {
display: flex; display: flex;
padding: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid var(--bg-slate-500); padding: 12px;
min-height: 44px;
border-top: 1px solid var(--bg-slate-500);
cursor: pointer; cursor: pointer;
.left-section { .left-section {
@@ -387,6 +366,16 @@
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
} }
} }
.context-links {
padding: 12px 12px 16px 12px;
border-bottom: 1px solid var(--bg-slate-500);
}
.thresholds-section {
padding: 12px 12px 16px 12px;
border-top: 1px solid var(--bg-slate-500);
}
} }
.select-option { .select-option {
@@ -418,9 +407,6 @@
} }
.name-description { .name-description {
border-top: 1px solid var(--bg-vanilla-300);
border-bottom: 1px solid var(--bg-vanilla-300);
.typography { .typography {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
@@ -441,8 +427,6 @@
} }
.panel-config { .panel-config {
border-bottom: 1px solid var(--bg-vanilla-300);
.typography { .typography {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
@@ -478,6 +462,9 @@
.fill-gaps-text { .fill-gaps-text {
color: var(--bg-ink-400); color: var(--bg-ink-400);
} }
.fill-gaps-text-description {
color: var(--bg-ink-400);
}
} }
.bucket-config { .bucket-config {
@@ -530,7 +517,7 @@
} }
.alerts { .alerts {
border-bottom: 1px solid var(--bg-vanilla-300); border-top: 1px solid var(--bg-vanilla-300);
.left-section { .left-section {
.bell-icon { .bell-icon {
@@ -549,6 +536,10 @@
.context-links { .context-links {
border-bottom: 1px solid var(--bg-vanilla-300); border-bottom: 1px solid var(--bg-vanilla-300);
} }
.thresholds-section {
border-top: 1px solid var(--bg-vanilla-300);
}
} }
.select-option { .select-option {

View File

@@ -1,5 +1,4 @@
.threshold-selector-container { .threshold-selector-container {
padding: 12px;
padding-bottom: 80px; padding-bottom: 80px;
.threshold-select { .threshold-select {

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import { Typography } from 'antd'; import { Button } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryLabels } from 'hooks/useGetQueryLabels'; import { useGetQueryLabels } from 'hooks/useGetQueryLabels';
import { Antenna, Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import Threshold from './Threshold'; import Threshold from './Threshold';
@@ -68,11 +68,14 @@ function ThresholdSelector({
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<div className="threshold-selector-container"> <div className="threshold-selector-container">
<div className="threshold-select" onClick={addThresholdHandler}> <div className="threshold-select" onClick={addThresholdHandler}>
<div className="left-section"> <Button
<Antenna size={14} className="icon" /> type="default"
<Typography.Text className="text">Thresholds</Typography.Text> icon={<Plus size={14} />}
</div> style={{ width: '100%' }}
<Plus size={14} onClick={addThresholdHandler} className="icon" /> onClick={addThresholdHandler}
>
Add Threshold
</Button>
</div> </div>
{thresholds.map((threshold, idx) => ( {thresholds.map((threshold, idx) => (
<Threshold <Threshold

View File

@@ -0,0 +1,68 @@
.settings-section {
border-top: 1px solid var(--bg-slate-500);
}
.settings-section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 12px;
min-height: 44px;
background: transparent;
border: none;
outline: none;
cursor: pointer;
.settings-section-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-vanilla-400);
font-family: 'Space Mono';
font-size: 13px;
font-weight: 400;
text-transform: uppercase;
}
.chevron-icon {
color: var(--bg-vanilla-400);
transition: transform 0.2s ease-in-out;
&.open {
transform: rotate(180deg);
}
}
}
.settings-section-content {
padding: 0 12px 0 12px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease;
&.open {
padding-bottom: 24px;
max-height: 1000px;
opacity: 1;
}
}
.lightMode {
.settings-section-header {
.chevron-icon {
color: var(--bg-ink-400);
}
.settings-section-title {
color: var(--bg-ink-400);
}
}
.settings-section {
border-top: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,51 @@
import { ReactNode, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import './SettingsSection.styles.scss';
export interface SettingsSectionProps {
title: string;
defaultOpen?: boolean;
children: ReactNode;
icon?: ReactNode;
}
function SettingsSection({
title,
defaultOpen = false,
children,
icon,
}: SettingsSectionProps): JSX.Element {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggleOpen = (): void => {
setIsOpen((prev) => !prev);
};
return (
<section className="settings-section">
<button
type="button"
className="settings-section-header"
onClick={toggleOpen}
>
<span className="settings-section-title">
{icon ? icon : null} {title}
</span>
<ChevronDown
size={16}
className={isOpen ? 'chevron-icon open' : 'chevron-icon'}
/>
</button>
<div
className={
isOpen ? 'settings-section-content open' : 'settings-section-content'
}
>
{children}
</div>
</section>
);
}
export default SettingsSection;

View File

@@ -14,7 +14,6 @@ import {
Input, Input,
InputNumber, InputNumber,
Select, Select,
Space,
Switch, Switch,
Typography, Typography,
} from 'antd'; } from 'antd';
@@ -28,9 +27,16 @@ import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts'; import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { import {
Antenna,
Axis3D,
ConciergeBell, ConciergeBell,
Layers,
LayoutDashboard,
LineChart, LineChart,
Link,
Pencil,
Plus, Plus,
SlidersHorizontal,
Spline, Spline,
SquareArrowOutUpRight, SquareArrowOutUpRight,
} from 'lucide-react'; } from 'lucide-react';
@@ -46,6 +52,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector'; import { ColumnUnitSelector } from './ColumnUnitSelector/ColumnUnitSelector';
import SettingsSection from './components/SettingsSection/SettingsSection';
import { import {
panelTypeVsBucketConfig, panelTypeVsBucketConfig,
panelTypeVsColumnUnitPreferences, panelTypeVsColumnUnitPreferences,
@@ -178,6 +185,21 @@ function RightContainer({
})); }));
}, [dashboardVariables]); }, [dashboardVariables]);
const isAxisSectionVisible = useMemo(() => allowSoftMinMax || allowLogScale, [
allowSoftMinMax,
allowLogScale,
]);
const isFormattingSectionVisible = useMemo(
() => allowYAxisUnit || allowDecimalPrecision || allowPanelColumnPreference,
[allowYAxisUnit, allowDecimalPrecision, allowPanelColumnPreference],
);
const isLegendSectionVisible = useMemo(
() => allowLegendPosition || allowLegendColors,
[allowLegendPosition, allowLegendColors],
);
const updateCursorAndDropdown = (value: string, pos: number): void => { const updateCursorAndDropdown = (value: string, pos: number): void => {
setCursorPos(pos); setCursorPos(pos);
const lastDollar = value.lastIndexOf('$', pos - 1); const lastDollar = value.lastIndexOf('$', pos - 1);
@@ -193,6 +215,15 @@ function RightContainer({
}, 0); }, 0);
}; };
const decimapPrecisionOptions = useMemo(() => {
return [
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
];
}, []);
const handleInputCursor = (): void => { const handleInputCursor = (): void => {
const pos = inputRef.current?.input?.selectionStart ?? 0; const pos = inputRef.current?.input?.selectionStart ?? 0;
updateCursorAndDropdown(inputValue, pos); updateCursorAndDropdown(inputValue, pos);
@@ -263,269 +294,297 @@ function RightContainer({
<div className="right-container"> <div className="right-container">
<section className="header"> <section className="header">
<div className="purple-dot" /> <div className="purple-dot" />
<Typography.Text className="header-text">Panel details</Typography.Text> <Typography.Text className="header-text">Panel Settings</Typography.Text>
</section> </section>
<section className="name-description">
<Typography.Text className="typography">Name</Typography.Text>
<AutoComplete
options={dashboardVariableOptions}
value={inputValue}
onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/>
</AutoComplete>
<Typography.Text className="typography">Description</Typography.Text>
<TextArea
placeholder="Enter the panel description here..."
bordered
allowClear
value={description}
onChange={(event): void =>
onChangeHandler(setDescription, event.target.value)
}
rootClassName="description-input"
/>
</section>
<section className="panel-config">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select
onChange={setGraphHandler}
value={selectedGraph}
style={{ width: '100%' }}
className="panel-type-select"
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
{allowFillSpans && ( <SettingsSection title="General" defaultOpen icon={<Pencil size={14} />}>
<Space className="fill-gaps"> <section className="name-description control-container">
<Typography className="fill-gaps-text">Fill gaps</Typography> <Typography.Text className="typography">Name</Typography.Text>
<Switch <AutoComplete
checked={isFillSpans} options={dashboardVariableOptions}
size="small" value={inputValue}
onChange={(checked): void => setIsFillSpans(checked)} onChange={onInputChange}
onSelect={onSelect}
filterOption={filterOption}
style={{ width: '100%' }}
getPopupContainer={popupContainer}
placeholder="Enter the panel name here..."
open={autoCompleteOpen}
>
<Input
rootClassName="name-input"
ref={inputRef}
onSelect={handleInputCursor}
onClick={handleInputCursor}
onBlur={(): void => setAutoCompleteOpen(false)}
/> />
</Space> </AutoComplete>
)} <Typography.Text className="typography">Description</Typography.Text>
<TextArea
{allowPanelTimePreference && ( placeholder="Enter the panel description here..."
<> bordered
<Typography.Text className="panel-time-text"> allowClear
Panel Time Preference value={description}
</Typography.Text> onChange={(event): void =>
<TimePreference onChangeHandler(setDescription, event.target.value)
{...{
selectedTime,
setSelectedTime,
}}
/>
</>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
} }
// Only update the y-axis unit value automatically in create mode rootClassName="description-input"
shouldUpdateYAxisUnit={isNewDashboard}
/> />
)} </section>
</SettingsSection>
{allowDecimalPrecision && ( <section className="panel-config">
<section className="decimal-precision-selector"> <SettingsSection
<Typography.Text className="typography"> title="Visualization"
Decimal Precision defaultOpen
</Typography.Text> icon={<LayoutDashboard size={14} />}
>
<section className="panel-type control-container">
<Typography.Text className="typography">Panel Type</Typography.Text>
<Select <Select
options={[ onChange={setGraphHandler}
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO }, value={selectedGraph}
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
]}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select" className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO} data-testid="panel-change-select"
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)} data-stacking-state={stackedBarChart ? 'true' : 'false'}
/> >
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>
<div className="select-option">
<div className="icon">{item.icon}</div>
<Typography.Text className="display">{item.display}</Typography.Text>
</div>
</Option>
))}
</Select>
</section> </section>
)}
{allowSoftMinMax && ( {allowPanelTimePreference && (
<section className="soft-min-max"> <section className="panel-time-preference control-container">
<section className="container"> <Typography.Text className="panel-time-text">
<Typography.Text className="text">Soft Min</Typography.Text> Panel Time Preference
<InputNumber </Typography.Text>
type="number" <TimePreference
value={softMin} {...{
onChange={softMinHandler} selectedTime,
rootClassName="input" setSelectedTime,
}}
/> />
</section> </section>
<section className="container"> )}
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber {allowStackingBarChart && (
value={softMax} <section className="stack-chart control-container">
type="number" <Typography.Text className="label">Stack series</Typography.Text>
rootClassName="input" <Switch
onChange={softMaxHandler} checked={stackedBarChart}
size="small"
onChange={(checked): void => setStackedBarChart(checked)}
/> />
</section> </section>
</section> )}
{allowFillSpans && (
<section className="fill-gaps">
<div className="fill-gaps-text-container">
<Typography className="fill-gaps-text">Fill gaps</Typography>
<Typography.Text className="fill-gaps-text-description">
Fill gaps in data with 0 for continuity
</Typography.Text>
</div>
<Switch
checked={isFillSpans}
size="small"
onChange={(checked): void => setIsFillSpans(checked)}
/>
</section>
)}
</SettingsSection>
{isFormattingSectionVisible && (
<SettingsSection
title="Formatting & Units"
icon={<SlidersHorizontal size={14} />}
>
{allowYAxisUnit && (
<DashboardYAxisUnitSelectorWrapper
onSelect={setYAxisUnit}
value={yAxisUnit || ''}
fieldLabel={
selectedGraphType === PanelDisplay.VALUE ||
selectedGraphType === PanelDisplay.PIE
? 'Unit'
: 'Y Axis Unit'
}
// Only update the y-axis unit value automatically in create mode
shouldUpdateYAxisUnit={isNewDashboard}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector control-container">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<Select
options={decimapPrecisionOptions}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowPanelColumnPreference && (
<ColumnUnitSelector
columnUnits={columnUnits}
setColumnUnits={setColumnUnits}
isNewDashboard={isNewDashboard}
/>
)}
</SettingsSection>
)} )}
{allowStackingBarChart && ( {isAxisSectionVisible && (
<section className="stack-chart"> <SettingsSection title="Axes" icon={<Axis3D size={14} />}>
<Typography.Text className="label">Stack series</Typography.Text> {allowSoftMinMax && (
<Switch <section className="soft-min-max">
checked={stackedBarChart} <section className="container">
size="small" <Typography.Text className="text">Soft Min</Typography.Text>
onChange={(checked): void => setStackedBarChart(checked)} <InputNumber
/> type="number"
</section> value={softMin}
onChange={softMinHandler}
rootClassName="input"
/>
</section>
<section className="container">
<Typography.Text className="text">Soft Max</Typography.Text>
<InputNumber
value={softMax}
type="number"
rootClassName="input"
onChange={softMaxHandler}
/>
</section>
</section>
)}
{allowLogScale && (
<section className="log-scale control-container">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void =>
setIsLogScale(value === LogScale.LOGARITHMIC)
}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
</SettingsSection>
)}
{isLegendSectionVisible && (
<SettingsSection title="Legend" icon={<Layers size={14} />}>
{allowLegendPosition && (
<section className="legend-position control-container">
<Typography.Text className="typography">Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)}
</SettingsSection>
)} )}
{allowBucketConfig && ( {allowBucketConfig && (
<section className="bucket-config"> <SettingsSection title="Histogram / Buckets">
<Typography.Text className="label">Number of buckets</Typography.Text> <section className="bucket-config control-container">
<InputNumber <Typography.Text className="label">Number of buckets</Typography.Text>
value={bucketCount || null} <InputNumber
type="number" value={bucketCount || null}
min={0} type="number"
rootClassName="bucket-input" min={0}
placeholder="Default: 30" rootClassName="bucket-input"
onChange={(val): void => { placeholder="Default: 30"
setBucketCount(val || 0); onChange={(val): void => {
}} setBucketCount(val || 0);
/> }}
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/> />
<Typography.Text className="label bucket-size-label">
Bucket width
</Typography.Text>
<InputNumber
value={bucketWidth || null}
type="number"
precision={2}
placeholder="Default: Auto"
step={0.1}
min={0.0}
rootClassName="bucket-input"
onChange={(val): void => {
setBucketWidth(val || 0);
}}
/>
<section className="combine-hist">
<Typography.Text className="label">
Merge all series into one
</Typography.Text>
<Switch
checked={combineHistogram}
size="small"
onChange={(checked): void => setCombineHistogram(checked)}
/>
</section>
</section> </section>
</section> </SettingsSection>
)}
{allowLogScale && (
<section className="log-scale">
<Typography.Text className="typography">Y Axis Scale</Typography.Text>
<Select
onChange={(value): void => setIsLogScale(value === LogScale.LOGARITHMIC)}
value={isLogScale ? LogScale.LOGARITHMIC : LogScale.LINEAR}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LogScale.LINEAR}
>
<Option value={LogScale.LINEAR}>
<div className="select-option">
<div className="icon">
<LineChart size={16} />
</div>
<Typography.Text className="display">Linear</Typography.Text>
</div>
</Option>
<Option value={LogScale.LOGARITHMIC}>
<div className="select-option">
<div className="icon">
<Spline size={16} />
</div>
<Typography.Text className="display">Logarithmic</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendPosition && (
<section className="legend-position">
<Typography.Text className="typography">Legend Position</Typography.Text>
<Select
onChange={(value: LegendPosition): void => setLegendPosition(value)}
value={legendPosition}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={LegendPosition.BOTTOM}
>
<Option value={LegendPosition.BOTTOM}>
<div className="select-option">
<Typography.Text className="display">Bottom</Typography.Text>
</div>
</Option>
<Option value={LegendPosition.RIGHT}>
<div className="select-option">
<Typography.Text className="display">Right</Typography.Text>
</div>
</Option>
</Select>
</section>
)}
{allowLegendColors && (
<section className="legend-colors">
<LegendColors
customLegendColors={customLegendColors}
setCustomLegendColors={setCustomLegendColors}
queryResponse={queryResponse}
/>
</section>
)} )}
</section> </section>
@@ -541,17 +600,25 @@ function RightContainer({
)} )}
{allowContextLinks && ( {allowContextLinks && (
<section className="context-links"> <SettingsSection
title="Context Links"
icon={<Link size={14} />}
defaultOpen={!!contextLinks.linksData.length}
>
<ContextLinks <ContextLinks
contextLinks={contextLinks} contextLinks={contextLinks}
setContextLinks={setContextLinks} setContextLinks={setContextLinks}
selectedWidget={selectedWidget} selectedWidget={selectedWidget}
/> />
</section> </SettingsSection>
)} )}
{allowThreshold && ( {allowThreshold && (
<section> <SettingsSection
title="Thresholds"
icon={<Antenna size={14} />}
defaultOpen={!!thresholds.length}
>
<ThresholdSelector <ThresholdSelector
thresholds={thresholds} thresholds={thresholds}
setThresholds={setThresholds} setThresholds={setThresholds}
@@ -559,7 +626,7 @@ function RightContainer({
selectedGraph={selectedGraph} selectedGraph={selectedGraph}
columnUnits={columnUnits} columnUnits={columnUnits}
/> />
</section> </SettingsSection>
)} )}
</div> </div>
); );

View File

@@ -36,7 +36,7 @@ const checkStackSeriesState = (
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument(); expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
const stackSeriesSection = container.querySelector( const stackSeriesSection = container.querySelector(
'section > .stack-chart', '.stack-chart',
) as HTMLElement; ) as HTMLElement;
expect(stackSeriesSection).toBeInTheDocument(); expect(stackSeriesSection).toBeInTheDocument();
@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
expect(getByText('Stack series')).toBeInTheDocument(); expect(getByText('Stack series')).toBeInTheDocument();
// Verify section exists // Verify section exists
const section = container.querySelector('section > .stack-chart'); const section = container.querySelector('.stack-chart');
expect(section).toBeInTheDocument(); expect(section).toBeInTheDocument();
// Verify switch is present and enabled (ant-switch-checked) // Verify switch is present and enabled (ant-switch-checked)

View File

@@ -439,6 +439,19 @@ function NewWidget({
globalSelectedInterval, globalSelectedInterval,
]); ]);
const navigateToDashboardPage = useCallback(() => {
const params = new URLSearchParams();
const urlVariablesQueryString = query.get(QueryParams.variables);
if (urlVariablesQueryString) {
params.set(QueryParams.variables, urlVariablesQueryString);
}
const search = params.toString() ? `?${params.toString()}` : '';
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }) + search);
}, [dashboardId, query, safeNavigate]);
const onClickSaveHandler = useCallback(() => { const onClickSaveHandler = useCallback(() => {
if (!selectedDashboard) { if (!selectedDashboard) {
return; return;
@@ -554,9 +567,7 @@ function NewWidget({
updateDashboardMutation.mutateAsync(dashboard, { updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: () => { onSuccess: () => {
setToScrollWidgetId(selectedWidget?.id || ''); setToScrollWidgetId(selectedWidget?.id || '');
safeNavigate({ navigateToDashboardPage();
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
});
}, },
}); });
}, [ }, [
@@ -572,7 +583,7 @@ function NewWidget({
updateDashboardMutation, updateDashboardMutation,
widgets, widgets,
setToScrollWidgetId, setToScrollWidgetId,
safeNavigate, navigateToDashboardPage,
dashboardId, dashboardId,
]); ]);
@@ -581,12 +592,12 @@ function NewWidget({
setDiscardModal(true); setDiscardModal(true);
return; return;
} }
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId })); navigateToDashboardPage();
}, [dashboardId, isQueryModified, safeNavigate]); }, [isQueryModified, navigateToDashboardPage]);
const discardChanges = useCallback(() => { const discardChanges = useCallback(() => {
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId })); navigateToDashboardPage();
}, [dashboardId, safeNavigate]); }, [navigateToDashboardPage]);
const setGraphHandler = (type: PANEL_TYPES): void => { const setGraphHandler = (type: PANEL_TYPES): void => {
setIsLoadingPanelData(true); setIsLoadingPanelData(true);
@@ -728,12 +739,14 @@ function NewWidget({
} }
const widgetId = query.get('widgetId') || ''; const widgetId = query.get('widgetId') || '';
const graphType = query.get('graphType') || ''; const graphType = query.get('graphType') || '';
const variables = query.get(QueryParams.variables) || '';
const queryParams = { const queryParams = {
[QueryParams.expandedWidgetId]: widgetId, [QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType, [QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent( [QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery), JSON.stringify(currentQuery),
), ),
[QueryParams.variables]: variables,
}; };
const updatedSearch = createQueryParams(queryParams); const updatedSearch = createQueryParams(queryParams);
@@ -822,56 +835,54 @@ function NewWidget({
</LeftContainerWrapper> </LeftContainerWrapper>
<RightContainerWrapper> <RightContainerWrapper>
<OverlayScrollbar> <RightContainer
<RightContainer setGraphHandler={setGraphHandler}
setGraphHandler={setGraphHandler} title={title}
title={title} setTitle={setTitle}
setTitle={setTitle} description={description}
description={description} setDescription={setDescription}
setDescription={setDescription} stackedBarChart={stackedBarChart}
stackedBarChart={stackedBarChart} setStackedBarChart={setStackedBarChart}
setStackedBarChart={setStackedBarChart} opacity={opacity}
opacity={opacity} yAxisUnit={yAxisUnit}
yAxisUnit={yAxisUnit} columnUnits={columnUnits}
columnUnits={columnUnits} setColumnUnits={setColumnUnits}
setColumnUnits={setColumnUnits} bucketCount={bucketCount}
bucketCount={bucketCount} bucketWidth={bucketWidth}
bucketWidth={bucketWidth} combineHistogram={combineHistogram}
combineHistogram={combineHistogram} setCombineHistogram={setCombineHistogram}
setCombineHistogram={setCombineHistogram} setBucketWidth={setBucketWidth}
setBucketWidth={setBucketWidth} setBucketCount={setBucketCount}
setBucketCount={setBucketCount} setOpacity={setOpacity}
setOpacity={setOpacity} selectedNullZeroValue={selectedNullZeroValue}
selectedNullZeroValue={selectedNullZeroValue} setSelectedNullZeroValue={setSelectedNullZeroValue}
setSelectedNullZeroValue={setSelectedNullZeroValue} selectedGraph={graphType}
selectedGraph={graphType} setSelectedTime={setSelectedTime}
setSelectedTime={setSelectedTime} selectedTime={selectedTime}
selectedTime={selectedTime} setYAxisUnit={setYAxisUnit}
setYAxisUnit={setYAxisUnit} decimalPrecision={decimalPrecision}
decimalPrecision={decimalPrecision} setDecimalPrecision={setDecimalPrecision}
setDecimalPrecision={setDecimalPrecision} thresholds={thresholds}
thresholds={thresholds} setThresholds={setThresholds}
setThresholds={setThresholds} selectedWidget={selectedWidget}
selectedWidget={selectedWidget} isFillSpans={isFillSpans}
isFillSpans={isFillSpans} setIsFillSpans={setIsFillSpans}
setIsFillSpans={setIsFillSpans} isLogScale={isLogScale}
isLogScale={isLogScale} setIsLogScale={setIsLogScale}
setIsLogScale={setIsLogScale} legendPosition={legendPosition}
legendPosition={legendPosition} setLegendPosition={setLegendPosition}
setLegendPosition={setLegendPosition} customLegendColors={customLegendColors}
customLegendColors={customLegendColors} setCustomLegendColors={setCustomLegendColors}
setCustomLegendColors={setCustomLegendColors} queryResponse={queryResponse}
queryResponse={queryResponse} softMin={softMin}
softMin={softMin} setSoftMin={setSoftMin}
setSoftMin={setSoftMin} softMax={softMax}
softMax={softMax} setSoftMax={setSoftMax}
setSoftMax={setSoftMax} contextLinks={contextLinks}
contextLinks={contextLinks} setContextLinks={setContextLinks}
setContextLinks={setContextLinks} enableDrillDown={enableDrillDown}
enableDrillDown={enableDrillDown} isNewDashboard={isNewDashboard}
isNewDashboard={isNewDashboard} />
/>
</OverlayScrollbar>
</RightContainerWrapper> </RightContainerWrapper>
</PanelContainer> </PanelContainer>
<Modal <Modal

View File

@@ -15,7 +15,14 @@ export const RightContainerWrapper = styled(Col)`
overflow-y: auto; overflow-y: auto;
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0rem; width: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
} }
`; `;

View File

@@ -0,0 +1,339 @@
import { renderHook } from '@testing-library/react';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
jest.mock('hooks/dashboard/useVariablesFromUrl');
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
typeof useDashboardVariablesFromLocalStorage
>;
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
typeof useVariablesFromUrl
>;
const makeVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'existing-id',
name: 'env',
description: '',
type: 'QUERY',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
selectedValue: 'prod',
...overrides,
});
const makeDashboard = (
variables: Record<string, IDashboardVariable>,
): Dashboard => ({
id: 'dash-1',
createdAt: '',
updatedAt: '',
createdBy: '',
updatedBy: '',
data: {
title: 'Test',
variables,
},
});
const setupHook = (
currentDashboard: Record<string, any> = {},
urlVariables: Record<string, any> = {},
): ReturnType<typeof useTransformDashboardVariables> => {
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
currentDashboard,
updateLocalStorageDashboardVariables: jest.fn(),
});
mockUseVariablesFromUrl.mockReturnValue({
getUrlVariables: () => urlVariables,
setUrlVariables: jest.fn(),
updateUrlVariable: jest.fn(),
});
const { result } = renderHook(() => useTransformDashboardVariables('dash-1'));
return result.current;
};
describe('useTransformDashboardVariables', () => {
beforeEach(() => jest.clearAllMocks());
describe('order assignment', () => {
it('assigns order starting from 0 to variables that have none', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
});
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
expect(orders).toContain(0);
expect(orders).toContain(1);
});
it('preserves existing order values', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: 5 }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.order).toBe(5);
});
it('assigns unique orders across multiple variables that all lack an order', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'v1', order: undefined }),
v2: makeVariable({ id: 'id2', name: 'v2', order: undefined }),
v3: makeVariable({ id: 'id3', name: 'v3', order: undefined }),
});
const result = transformDashboardVariables(dashboard);
const orders = Object.values(result.data.variables).map((v) => v.order);
// All three newly assigned orders must be distinct
expect(new Set(orders).size).toBe(3);
});
});
describe('ID assignment', () => {
it('assigns a UUID to variables that have no id', () => {
const { transformDashboardVariables } = setupHook();
const variable = makeVariable({ name: 'v1' });
(variable as any).id = undefined;
const dashboard = makeDashboard({ v1: variable });
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('preserves existing IDs', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({ id: 'keep-me', name: 'v1' }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.id).toBe('keep-me');
});
});
describe('TEXTBOX backward compatibility', () => {
it('copies textboxValue to defaultValue when defaultValue is missing', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'v1',
type: 'TEXTBOX',
textboxValue: 'hello',
defaultValue: undefined,
order: undefined,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('hello');
});
it('does not overwrite an existing defaultValue', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'v1',
type: 'TEXTBOX',
textboxValue: 'old',
defaultValue: 'keep',
order: undefined,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.defaultValue).toBe('keep');
});
});
describe('localStorage merge', () => {
it('applies localStorage selectedValue over DB value', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: 'staging', allSelected: false },
});
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('staging');
});
it('applies localStorage allSelected over DB value', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: undefined, allSelected: true },
});
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
allSelected: false,
showALLOption: true,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
});
});
describe('URL variable override', () => {
it('sets allSelected=true when URL value is __ALL__', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: 'prod', allSelected: false } },
{ env: '__ALL__' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: true,
allSelected: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.allSelected).toBe(true);
});
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: undefined, allSelected: true } },
{ env: 'dev' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: true,
allSelected: true,
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(false);
});
it('does not set allSelected=false when showALLOption is false', () => {
const { transformDashboardVariables } = setupHook(
{ env: { selectedValue: undefined, allSelected: true } },
{ env: 'dev' },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
showALLOption: false,
allSelected: true,
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('dev');
expect(result.data.variables.v1.allSelected).toBe(true);
});
it('normalizes array URL value to single value for single-select variable', () => {
const { transformDashboardVariables } = setupHook(
{},
{ env: ['prod', 'dev'] },
);
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
multiSelect: false,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('prod');
});
it('wraps single URL value in array for multi-select variable', () => {
const { transformDashboardVariables } = setupHook({}, { env: 'prod' });
const dashboard = makeDashboard({
v1: makeVariable({
id: 'id1',
name: 'env',
multiSelect: true,
}),
});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toEqual(['prod']);
});
it('looks up URL variable by variable id when name is absent', () => {
const { transformDashboardVariables } = setupHook(
{},
{ 'var-uuid': 'fallback' },
);
const variable = makeVariable({ id: 'var-uuid', multiSelect: false });
delete variable.name;
const dashboard = makeDashboard({ v1: variable });
const result = transformDashboardVariables(dashboard);
expect(result.data.variables.v1.selectedValue).toBe('fallback');
});
});
describe('edge cases', () => {
it('returns data unchanged when there are no variables', () => {
const { transformDashboardVariables } = setupHook();
const dashboard = makeDashboard({});
const result = transformDashboardVariables(dashboard);
expect(result.data.variables).toEqual({});
});
it('does not mutate the original dashboard', () => {
const { transformDashboardVariables } = setupHook({
env: { selectedValue: 'staging', allSelected: false },
});
const dashboard = makeDashboard({
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
});
const originalValue = dashboard.data.variables.v1.selectedValue;
transformDashboardVariables(dashboard);
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
});
});
});

View File

@@ -15,7 +15,7 @@ interface DashboardLocalStorageVariables {
[id: string]: LocalStoreDashboardVariables; [id: string]: LocalStoreDashboardVariables;
} }
interface UseDashboardVariablesFromLocalStorageReturn { export interface UseDashboardVariablesFromLocalStorageReturn {
currentDashboard: LocalStoreDashboardVariables; currentDashboard: LocalStoreDashboardVariables;
updateLocalStorageDashboardVariables: ( updateLocalStorageDashboardVariables: (
id: string, id: string,

View File

@@ -0,0 +1,128 @@
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import {
useDashboardVariablesFromLocalStorage,
UseDashboardVariablesFromLocalStorageReturn,
} from 'hooks/dashboard/useDashboardFromLocalStorage';
import useVariablesFromUrl, {
UseVariablesFromUrlReturn,
} from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as generateUUID } from 'uuid';
export function useTransformDashboardVariables(
dashboardId: string,
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
UseDashboardVariablesFromLocalStorageReturn & {
transformDashboardVariables: (data: Dashboard) => Dashboard;
} {
const {
currentDashboard,
updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId);
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (!isEmpty(urlVariable)) {
if (urlVariable === ALL_SELECTED_VALUE) {
updatedVariable = {
...updatedVariable,
allSelected: true,
};
} else {
// Normalize URL value to match variable's multiSelect configuration
const normalizedValue = normalizeUrlValueForVariable(
urlVariable,
variableData,
);
updatedVariable = {
...updatedVariable,
selectedValue: normalizedValue,
// Only set allSelected to false if showALLOption is available
...(updatedVariable?.showALLOption && { allSelected: false }),
};
}
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
}
return updatedData;
};
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
// eslint-disable-next-line sonarjs/cognitive-complexity
const transformDashboardVariables = (data: Dashboard): Dashboard => {
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
for (const key in variables) {
// eslint-disable-next-line no-prototype-builtins
if (variables.hasOwnProperty(key)) {
const variable: IDashboardVariable = variables[key];
// Check if 'order' property doesn't exist or is undefined
if (variable.order === undefined) {
// Find a unique order starting from 0
let order = 0;
while (existingOrders.has(order)) {
order += 1;
}
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {
variable.id = generateUUID();
}
}
}
return clonedDashboardData;
}
return data;
};
return {
transformDashboardVariables,
getUrlVariables,
updateUrlVariable,
currentDashboard,
updateLocalStorageDashboardVariables,
};
}

View File

@@ -11,7 +11,7 @@ export interface LocalStoreDashboardVariables {
| IDashboardVariable['selectedValue']; | IDashboardVariable['selectedValue'];
} }
interface UseVariablesFromUrlReturn { export interface UseVariablesFromUrlReturn {
getUrlVariables: () => LocalStoreDashboardVariables; getUrlVariables: () => LocalStoreDashboardVariables;
setUrlVariables: (variables: LocalStoreDashboardVariables) => void; setUrlVariables: (variables: LocalStoreDashboardVariables) => void;
updateUrlVariable: ( updateUrlVariable: (

View File

@@ -0,0 +1,143 @@
import { Route } from 'react-router-dom';
import * as getDashboardModule from 'api/v1/dashboards/id/get';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import DashboardWidget from '../index';
const DASHBOARD_ID = 'dash-1';
const WIDGET_ID = 'widget-abc';
const mockDashboardResponse = {
status: 'success',
data: {
id: DASHBOARD_ID,
createdAt: '2024-01-01T00:00:00Z',
createdBy: 'test',
updatedAt: '2024-01-01T00:00:00Z',
updatedBy: 'test',
isLocked: false,
data: {
collapsableRowsMigrated: true,
description: '',
name: '',
panelMap: {},
tags: [],
title: 'Test Dashboard',
uploadedGrafana: false,
uuid: '',
version: '',
variables: {},
widgets: [],
layout: [],
},
},
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('container/NewWidget', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="new-widget">NewWidget</div>,
}));
// Wrap component in a Route so useParams can resolve dashboardId.
// Query params are passed via the URL so useUrlQuery (react-router) can read them.
function renderAtRoute(
queryState: Record<string, string | null> = {},
): ReturnType<typeof render> {
const params = new URLSearchParams();
Object.entries(queryState).forEach(([k, v]) => {
if (v !== null) {
params.set(k, v);
}
});
const search = params.toString() ? `?${params.toString()}` : '';
return render(
<Route path="/dashboard/:dashboardId/new">
<DashboardWidget />
</Route>,
undefined,
{ initialRoute: `/dashboard/${DASHBOARD_ID}/new${search}` },
);
}
beforeEach(() => {
mockSafeNavigate.mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('DashboardWidget', () => {
it('redirects to dashboard when widgetId is missing', async () => {
renderAtRoute({ graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
});
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
});
it('redirects to dashboard when graphType is missing', async () => {
renderAtRoute({ widgetId: WIDGET_ID });
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalled();
});
const [navigatedTo] = mockSafeNavigate.mock.calls[0];
expect(navigatedTo).toContain(`/dashboard/${DASHBOARD_ID}`);
});
it('shows spinner while dashboard is loading', () => {
// Spy instead of MSW delay('infinite') to avoid leaving an open network handle.
jest
.spyOn(getDashboardModule, 'default')
.mockReturnValue(new Promise(() => {}));
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
expect(screen.getByRole('img', { name: 'loading' })).toBeInTheDocument();
});
it('shows error message when dashboard fetch fails', async () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});
it('renders NewWidget when dashboard loads successfully', async () => {
server.use(
rest.get(
`http://localhost/api/v1/dashboards/${DASHBOARD_ID}`,
(_req, res, ctx) => res(ctx.status(200), ctx.json(mockDashboardResponse)),
),
);
renderAtRoute({ widgetId: WIDGET_ID, graphType: PANEL_TYPES.TIME_SERIES });
await waitFor(() => {
expect(screen.getByTestId('new-widget')).toBeInTheDocument();
});
});
});

View File

@@ -1,29 +1,34 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { generatePath, useParams } from 'react-router-dom'; import { generatePath, useParams } from 'react-router-dom';
import { Card, Typography } from 'antd'; import { Card, Typography } from 'antd';
import getDashboard from 'api/v1/dashboards/id/get'; import getDashboard from 'api/v1/dashboards/id/get';
import Spinner from 'components/Spinner'; import Spinner from 'components/Spinner';
import { SOMETHING_WENT_WRONG } from 'constants/api'; import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime'; import { DASHBOARD_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import NewWidget from 'container/NewWidget'; import NewWidget from 'container/NewWidget';
import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils'; import { isDrilldownEnabled } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate'; import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { parseAsStringEnum, useQueryState } from 'nuqs'; import useUrlQuery from 'hooks/useUrlQuery';
import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore'; import { setDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { Dashboard } from 'types/api/dashboard/getAll';
function DashboardWidget(): JSX.Element | null { function DashboardWidget(): JSX.Element | null {
const { dashboardId } = useParams<{ const { dashboardId } = useParams<{
dashboardId: string; dashboardId: string;
}>(); }>();
const [widgetId] = useQueryState('widgetId'); const query = useUrlQuery();
const [graphType] = useQueryState( const { graphType, widgetId } = useMemo(() => {
'graphType', return {
parseAsStringEnum<PANEL_TYPES>(Object.values(PANEL_TYPES)), graphType: query.get(QueryParams.graphType) as PANEL_TYPES,
); widgetId: query.get(QueryParams.widgetId),
};
}, [query]);
const { safeNavigate } = useSafeNavigate(); const { safeNavigate } = useSafeNavigate();
@@ -57,8 +62,15 @@ function DashboardWidgetInternal({
widgetId: string; widgetId: string;
graphType: PANEL_TYPES; graphType: PANEL_TYPES;
}): JSX.Element | null { }): JSX.Element | null {
const [selectedDashboard, setSelectedDashboard] = useState<
Dashboard | undefined
>(undefined);
const { transformDashboardVariables } = useTransformDashboardVariables(
dashboardId,
);
const { const {
data: dashboardResponse,
isFetching: isFetchingDashboardResponse, isFetching: isFetchingDashboardResponse,
isError: isErrorDashboardResponse, isError: isErrorDashboardResponse,
} = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], { } = useQuery([REACT_QUERY_KEY.DASHBOARD_BY_ID, dashboardId, widgetId], {
@@ -70,17 +82,15 @@ function DashboardWidgetInternal({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
cacheTime: DASHBOARD_CACHE_TIME, cacheTime: DASHBOARD_CACHE_TIME,
onSuccess: (response) => { onSuccess: (response) => {
const updatedDashboardData = transformDashboardVariables(response.data);
setSelectedDashboard(updatedDashboardData);
setDashboardVariablesStore({ setDashboardVariablesStore({
dashboardId, dashboardId,
variables: response.data.data.variables, variables: updatedDashboardData.data.variables,
}); });
}, },
}); });
const selectedDashboard = useMemo(() => dashboardResponse?.data, [
dashboardResponse?.data,
]);
if (isFetchingDashboardResponse) { if (isFetchingDashboardResponse) {
return <Spinner tip="Loading.." />; return <Spinner tip="Loading.." />;
} }

View File

@@ -17,21 +17,18 @@ import { useDispatch, useSelector } from 'react-redux';
import { Modal } from 'antd'; import { Modal } from 'antd';
import getDashboard from 'api/v1/dashboards/id/get'; import getDashboard from 'api/v1/dashboards/id/get';
import locked from 'api/v1/dashboards/id/lock'; import locked from 'api/v1/dashboards/id/lock';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage'; import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import useTabVisibility from 'hooks/useTabFocus'; import useTabVisibility from 'hooks/useTabFocus';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout'; import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import { getMinMaxForSelectedTime } from 'lib/getMinMax'; import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { defaultTo, isEmpty } from 'lodash-es'; import { defaultTo } from 'lodash-es';
import isEqual from 'lodash-es/isEqual'; import isEqual from 'lodash-es/isEqual';
import isUndefined from 'lodash-es/isUndefined'; import isUndefined from 'lodash-es/isUndefined';
import omitBy from 'lodash-es/omitBy'; import omitBy from 'lodash-es/omitBy';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables'; import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { normalizeUrlValueForVariable } from 'providers/Dashboard/normalizeUrlValue';
import { useErrorModal } from 'providers/ErrorModalProvider'; import { useErrorModal } from 'providers/ErrorModalProvider';
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@@ -39,10 +36,9 @@ import { AppState } from 'store/reducers';
import AppActions from 'types/actions'; import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime'; import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { SuccessResponseV2 } from 'types/api'; import { SuccessResponseV2 } from 'types/api';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll'; import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import { import {
DASHBOARD_CACHE_TIME, DASHBOARD_CACHE_TIME,
@@ -137,9 +133,10 @@ export function DashboardProvider({
const { const {
currentDashboard, currentDashboard,
updateLocalStorageDashboardVariables, updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId); getUrlVariables,
updateUrlVariable,
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl(); transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
const modalRef = useRef<any>(null); const modalRef = useRef<any>(null);
@@ -151,99 +148,6 @@ export function DashboardProvider({
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false); const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
const mergeDBWithLocalStorage = (
data: Dashboard,
localStorageVariables: any,
): Dashboard => {
const updatedData = data;
if (data && localStorageVariables) {
const updatedVariables = data.data.variables;
const variablesFromUrl = getUrlVariables();
Object.keys(data.data.variables).forEach((variable) => {
const variableData = data.data.variables[variable];
// values from url
const urlVariable = variableData?.name
? variablesFromUrl[variableData?.name] || variablesFromUrl[variableData.id]
: variablesFromUrl[variableData.id];
let updatedVariable = {
...data.data.variables[variable],
...localStorageVariables[variableData.name as any],
};
// respect the url variable if it is set, override the others
if (!isEmpty(urlVariable)) {
if (urlVariable === ALL_SELECTED_VALUE) {
updatedVariable = {
...updatedVariable,
allSelected: true,
};
} else {
// Normalize URL value to match variable's multiSelect configuration
const normalizedValue = normalizeUrlValueForVariable(
urlVariable,
variableData,
);
updatedVariable = {
...updatedVariable,
selectedValue: normalizedValue,
// Only set allSelected to false if showALLOption is available
...(updatedVariable?.showALLOption && { allSelected: false }),
};
}
}
updatedVariables[variable] = updatedVariable;
});
updatedData.data.variables = updatedVariables;
}
return updatedData;
};
// As we do not have order and ID's in the variables object, we have to process variables to add order and ID if they do not exist in the variables object
// eslint-disable-next-line sonarjs/cognitive-complexity
const transformDashboardVariables = (data: Dashboard): Dashboard => {
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
for (const key in variables) {
// eslint-disable-next-line no-prototype-builtins
if (variables.hasOwnProperty(key)) {
const variable: IDashboardVariable = variables[key];
// Check if 'order' property doesn't exist or is undefined
if (variable.order === undefined) {
// Find a unique order starting from 0
let order = 0;
while (existingOrders.has(order)) {
order += 1;
}
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {
variable.id = generateUUID();
}
}
}
return clonedDashboardData;
}
return data;
};
const dashboardResponse = useQuery( const dashboardResponse = useQuery(
[ [
REACT_QUERY_KEY.DASHBOARD_BY_ID, REACT_QUERY_KEY.DASHBOARD_BY_ID,
@@ -274,13 +178,14 @@ export function DashboardProvider({
}, },
onSuccess: (data: SuccessResponseV2<Dashboard>) => { onSuccess: (data: SuccessResponseV2<Dashboard>) => {
// if the url variable is not set for any variable, set it to the default value const updatedDashboardData = transformDashboardVariables(data?.data);
const variables = data?.data?.data?.variables;
// initialize URL variables after dashboard state is set to avoid race conditions
const variables = updatedDashboardData?.data?.variables;
if (variables) { if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable); initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
} }
const updatedDashboardData = transformDashboardVariables(data?.data);
const updatedDate = dayjs(updatedDashboardData?.updatedAt); const updatedDate = dayjs(updatedDashboardData?.updatedAt);
setIsDashboardLocked(updatedDashboardData?.locked || false); setIsDashboardLocked(updatedDashboardData?.locked || false);

View File

@@ -381,6 +381,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
multiSelect: false, multiSelect: false,
allSelected: false, allSelected: false,
showALLOption: true, showALLOption: true,
order: 0,
}, },
services: { services: {
id: 'svc-id', id: 'svc-id',
@@ -388,6 +389,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
multiSelect: true, multiSelect: true,
allSelected: false, allSelected: false,
showALLOption: true, showALLOption: true,
order: 1,
}, },
}, },
mockGetUrlVariables, mockGetUrlVariables,

View File

@@ -0,0 +1,93 @@
package cloudintegration
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// CreateConnectionArtifact generates cloud provider specific connection information,
// client side handles how this information is shown
CreateConnectionArtifact(
ctx context.Context,
orgID valuer.UUID,
provider cloudintegrationtypes.CloudProviderType,
request *cloudintegrationtypes.ConnectionArtifactRequest,
) (*cloudintegrationtypes.ConnectionArtifact, error)
// GetAccountStatus returns agent connection status for a cloud integration account
GetAccountStatus(ctx context.Context, orgID, accountID valuer.UUID) (*cloudintegrationtypes.AccountStatus, error)
// ListConnectedAccounts lists accounts where agent is connected
ListConnectedAccounts(ctx context.Context, orgID valuer.UUID) (*cloudintegrationtypes.ConnectedAccounts, error)
// DisconnectAccount soft deletes/removes a cloud integration account.
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
// UpdateAccountConfig updates the configuration of an existing cloud account for a specific organization.
UpdateAccountConfig(
ctx context.Context,
orgID,
accountID valuer.UUID,
config *cloudintegrationtypes.UpdateAccountConfigRequest,
) (*cloudintegrationtypes.Account, error)
// ListServicesMetadata returns list of services metadata for a cloud provider attached with the integrationID.
// This just returns a summary of the service and not the whole service definition
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) (*cloudintegrationtypes.ServicesMetadata, error)
// GetService returns service definition details for a serviceID. This returns config and
// other details required to show in service details page on web client.
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*cloudintegrationtypes.Service, error)
// UpdateServiceConfig updates cloud integration service config
UpdateServiceConfig(
ctx context.Context,
orgID valuer.UUID,
serviceID string,
config *cloudintegrationtypes.UpdateServiceConfigRequest,
) (*cloudintegrationtypes.UpdateServiceConfigResponse, error)
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
AgentCheckIn(
ctx context.Context,
orgID valuer.UUID,
req *cloudintegrationtypes.AgentCheckInRequest,
) (*cloudintegrationtypes.AgentCheckInResponse, error)
// GetDashboardByID returns dashboard JSON for a given dashboard id.
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
// in the org for any cloud integration account
GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error)
// GetAllDashboards returns list of dashboards across all connected cloud integration accounts
// for enabled services in the org. This list gets added to dashboard list page
GetAllDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
}
// CloudProvider is the interface each cloud provider must implement.
type CloudProvider interface {
CreateArtifact(
ctx context.Context,
orgID valuer.UUID,
request *cloudintegrationtypes.ConnectionArtifactRequest,
creds cloudintegrationtypes.SignozCredentials,
accountID valuer.UUID,
) (artifact *cloudintegrationtypes.ConnectionArtifact, err error)
}
type Handler interface {
AgentCheckIn(http.ResponseWriter, *http.Request)
GenerateConnectionArtifact(http.ResponseWriter, *http.Request)
ListConnectedAccounts(http.ResponseWriter, *http.Request)
GetAccountStatus(http.ResponseWriter, *http.Request)
ListServices(http.ResponseWriter, *http.Request)
GetServiceDetails(http.ResponseWriter, *http.Request)
UpdateAccountConfig(http.ResponseWriter, *http.Request)
UpdateServiceConfig(http.ResponseWriter, *http.Request)
DisconnectAccount(http.ResponseWriter, *http.Request)
}

View File

@@ -0,0 +1,61 @@
package implcloudintegration
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct{}
func NewModule() cloudintegration.Module {
return &module{}
}
func (m *module) CreateConnectionArtifact(_ context.Context, _ valuer.UUID, _ cloudintegrationtypes.CloudProviderType, _ *cloudintegrationtypes.ConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) GetAccountStatus(_ context.Context, _, _ valuer.UUID) (*cloudintegrationtypes.AccountStatus, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) ListConnectedAccounts(_ context.Context, _ valuer.UUID) (*cloudintegrationtypes.ConnectedAccounts, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) DisconnectAccount(_ context.Context, _, _ valuer.UUID) error {
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) UpdateAccountConfig(_ context.Context, _, _ valuer.UUID, _ *cloudintegrationtypes.UpdateAccountConfigRequest) (*cloudintegrationtypes.Account, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) ListServicesSummary(_ context.Context, _ valuer.UUID, _ *valuer.UUID) (*cloudintegrationtypes.ServicesSummary, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) GetService(_ context.Context, _ valuer.UUID, _ string, _ *valuer.UUID) (*cloudintegrationtypes.Service, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) UpdateServiceConfig(_ context.Context, _ string, _ valuer.UUID, _ *cloudintegrationtypes.UpdateServiceConfigRequest) (*cloudintegrationtypes.ServiceSummary, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) AgentCheckIn(_ context.Context, _ valuer.UUID, _ *cloudintegrationtypes.AgentCheckInRequest) (cloudintegrationtypes.AgentCheckInResponse, error) {
return cloudintegrationtypes.AgentCheckInResponse{}, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) GetDashboardByID(_ context.Context, _ string, _ valuer.UUID) (*dashboardtypes.Dashboard, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}
func (m *module) GetAllDashboards(_ context.Context, _ valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "cloud integration is an enterprise feature")
}

View File

@@ -0,0 +1,118 @@
package implcloudintegration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type store struct {
store sqlstore.SQLStore
}
func NewStore(sqlStore sqlstore.SQLStore) cloudintegrationtypes.Store {
return &store{store: sqlStore}
}
func (s *store) GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := s.store.BunDB().NewSelect().Model(account).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Scan(ctx)
if err != nil {
return nil, s.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration account with id %s not found", id)
}
return account, nil
}
func (s *store) UpsertAccount(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) error {
account.UpdatedAt = time.Now()
_, err := s.store.BunDBCtx(ctx).NewInsert().Model(account).
On("CONFLICT (id, provider, org_id) DO UPDATE").
Set("config = EXCLUDED.config").
Set("account_id = EXCLUDED.account_id").
Set("last_agent_report = EXCLUDED.last_agent_report").
Set("removed_at = EXCLUDED.removed_at").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
return err
}
func (s *store) RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider cloudintegrationtypes.CloudProviderType) error {
_, err := s.store.BunDBCtx(ctx).NewUpdate().Model((*cloudintegrationtypes.StorableCloudIntegration)(nil)).
Set("removed_at = ?", time.Now()).
Where("id = ?", id).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Exec(ctx)
return err
}
func (s *store) GetConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType) ([]*cloudintegrationtypes.StorableCloudIntegration, error) {
var accounts []*cloudintegrationtypes.StorableCloudIntegration
err := s.store.BunDB().NewSelect().Model(&accounts).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("removed_at IS NULL").
Where("account_id IS NOT NULL").
Where("last_agent_report IS NOT NULL").
Order("created_at ASC").
Scan(ctx)
if err != nil {
return nil, err
}
return accounts, nil
}
func (s *store) GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, providerAccountID string) (*cloudintegrationtypes.StorableCloudIntegration, error) {
account := new(cloudintegrationtypes.StorableCloudIntegration)
err := s.store.BunDB().NewSelect().Model(account).
Where("org_id = ?", orgID).
Where("provider = ?", provider).
Where("account_id = ?", providerAccountID).
Where("last_agent_report IS NOT NULL").
Where("removed_at IS NULL").
Scan(ctx)
if err != nil {
return nil, s.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "connected account with provider account id %s not found", providerAccountID)
}
return account, nil
}
func (s *store) GetServiceByType(ctx context.Context, cloudIntegrationID valuer.UUID, serviceType string) (*cloudintegrationtypes.StorableCloudIntegrationService, error) {
service := new(cloudintegrationtypes.StorableCloudIntegrationService)
err := s.store.BunDB().NewSelect().Model(service).
Where("cloud_integration_id = ?", cloudIntegrationID).
Where("type = ?", serviceType).
Scan(ctx)
if err != nil {
return nil, s.store.WrapNotFoundErrf(err, cloudintegrationtypes.ErrCodeCloudIntegrationNotFound, "cloud integration service with type %s not found", serviceType)
}
return service, nil
}
func (s *store) UpsertService(ctx context.Context, service *cloudintegrationtypes.StorableCloudIntegrationService) error {
service.UpdatedAt = time.Now()
_, err := s.store.BunDBCtx(ctx).NewInsert().Model(service).
On("CONFLICT (cloud_integration_id, type) DO UPDATE").
Set("config = EXCLUDED.config").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)
return err
}
func (s *store) GetServices(ctx context.Context, cloudIntegrationID valuer.UUID) ([]*cloudintegrationtypes.StorableCloudIntegrationService, error) {
var services []*cloudintegrationtypes.StorableCloudIntegrationService
err := s.store.BunDB().NewSelect().Model(&services).
Where("cloud_integration_id = ?", cloudIntegrationID).
Scan(ctx)
if err != nil {
return nil, err
}
return services, nil
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory/factorytest" "github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger" "github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -50,7 +51,7 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger) userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter) modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, implcloudintegration.NewModule())
querierHandler := querier.NewHandler(providerSettings, nil, nil) querierHandler := querier.NewHandler(providerSettings, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil) handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil)

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex" "github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
"github.com/SigNoz/signoz/pkg/modules/authdomain" "github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain" "github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer" "github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer" "github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
@@ -51,24 +52,25 @@ import (
) )
type Modules struct { type Modules struct {
OrgGetter organization.Getter OrgGetter organization.Getter
OrgSetter organization.Setter OrgSetter organization.Setter
Preference preference.Module Preference preference.Module
User user.Module User user.Module
UserGetter user.Getter UserGetter user.Getter
SavedView savedview.Module SavedView savedview.Module
Apdex apdex.Module Apdex apdex.Module
Dashboard dashboard.Module Dashboard dashboard.Module
QuickFilter quickfilter.Module QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module RawDataExport rawdataexport.Module
AuthDomain authdomain.Module AuthDomain authdomain.Module
Session session.Module Session session.Module
Services services.Module Services services.Module
SpanPercentile spanpercentile.Module SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module MetricsExplorer metricsexplorer.Module
Promote promote.Module Promote promote.Module
ServiceAccount serviceaccount.Module ServiceAccount serviceaccount.Module
CloudIntegration cloudintegration.Module
} }
func NewModules( func NewModules(
@@ -89,6 +91,7 @@ func NewModules(
config Config, config Config,
dashboard dashboard.Module, dashboard dashboard.Module,
userGetter user.Getter, userGetter user.Getter,
cloudIntegration cloudintegration.Module,
) Modules { ) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore)) quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter) orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -96,23 +99,24 @@ func NewModules(
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings) ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{ return Modules{
OrgGetter: orgGetter, OrgGetter: orgGetter,
OrgSetter: orgSetter, OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()), Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore), SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore), Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard, Dashboard: dashboard,
User: user, User: user,
UserGetter: userGetter, UserGetter: userGetter,
QuickFilter: quickfilter, QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)), TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier), RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter), Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings), SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore), Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer), MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, providerSettings, config.MetricsExplorer),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore), Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, emailing, providerSettings), ServiceAccount: implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), authz, emailing, providerSettings),
CloudIntegration: cloudIntegration,
} }
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory/factorytest" "github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger" "github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -49,7 +50,7 @@ func TestNewModules(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger) userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter) modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, implcloudintegration.NewModule())
reflectVal := reflect.ValueOf(modules) reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ { for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/gateway" "github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard" "github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
@@ -43,6 +44,7 @@ import (
"github.com/SigNoz/signoz/pkg/version" "github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus" "github.com/SigNoz/signoz/pkg/zeus"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/web" "github.com/SigNoz/signoz/pkg/web"
) )
@@ -91,6 +93,7 @@ func New(
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module, dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config], gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler, querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
cloudIntegrationModuleCallback func(sqlstore.SQLStore, licensing.Licensing, zeus.Zeus, gateway.Gateway, global.Config) cloudintegration.Module,
) (*SigNoz, error) { ) (*SigNoz, error) {
// Initialize instrumentation // Initialize instrumentation
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz") instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
@@ -387,8 +390,11 @@ func New(
return nil, err return nil, err
} }
// Initialize cloudintegration module via callback
cloudIntegrationModule := cloudIntegrationModuleCallback(sqlstore, licensing, zeus, gateway, config.Global)
// Initialize all modules // Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter) modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, cloudIntegrationModule)
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root) userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root)

View File

@@ -0,0 +1,49 @@
package cloudintegrationtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/valuer"
)
type (
ConnectedAccounts struct {
Accounts []*Account `json:"accounts"`
}
GettableConnectedAccounts = ConnectedAccounts
UpdateAccountConfigRequest struct {
AWS *AWSAccountConfig `json:"aws"`
}
UpdatableAccountConfig = UpdateAccountConfigRequest
)
type (
Account struct {
Id string `json:"id"`
ProviderAccountId *string `json:"providerAccountID,omitempty"`
Provider CloudProviderType `json:"provider"`
RemovedAt *time.Time `json:"removedAt,omitempty"`
AgentReport *AgentReport `json:"agentReport,omitempty"`
OrgID valuer.UUID `json:"orgID"`
Config *AccountConfig `json:"accountConfig,omitempty"`
}
GettableAccount = Account
)
// AgentReport represents heartbeats sent by the agent.
type AgentReport struct {
TimestampMillis int64 `json:"timestampMillis"`
Data map[string]any `json:"data"`
}
type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws,omitempty"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions"`
}

View File

@@ -0,0 +1,82 @@
package cloudintegrationtypes
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/uptrace/bun"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ErrCodeCloudIntegrationNotFound = errors.MustNewCode("cloud_integration_not_found")
)
// StorableCloudIntegration represents a cloud integration stored in the database.
// This is also referred as "Account" in the context of cloud integrations.
type StorableCloudIntegration struct {
bun.BaseModel `bun:"table:cloud_integration"`
types.Identifiable
types.TimeAuditable
Provider CloudProviderType `json:"provider" bun:"provider,type:text"`
// Config is provider specific data in JSON string format
Config string `json:"config" bun:"config,type:text"`
AccountID *string `json:"account_id" bun:"account_id,type:text"`
LastAgentReport *StorableAgentReport `json:"last_agent_report" bun:"last_agent_report,type:text"`
RemovedAt *time.Time `json:"removed_at" bun:"removed_at,type:timestamp,nullzero"`
OrgID valuer.UUID `bun:"org_id,type:text"`
}
// StorableAgentReport represents the last heartbeat and arbitrary data sent by the agent
// as of now there is no use case for Data field, but keeping it for backwards compatibility with older structure.
type StorableAgentReport struct {
TimestampMillis int64 `json:"timestamp_millis"`
Data map[string]any `json:"data"`
}
// StorableCloudIntegrationService is to store service config for a cloud integration, which is a cloud provider specific configuration.
type StorableCloudIntegrationService struct {
bun.BaseModel `bun:"table:cloud_integration_service,alias:cis"`
types.Identifiable
types.TimeAuditable
Type valuer.String `bun:"type,type:text,notnull,unique:cloud_integration_id_type"`
// Config is cloud provider's service specific data in JSON string format
Config string `bun:"config,type:text"`
CloudIntegrationID valuer.UUID `bun:"cloud_integration_id,type:text,notnull,unique:cloud_integration_id_type,references:cloud_integration(id),on_delete:cascade"`
}
// Scan scans value from DB.
func (r *StorableAgentReport) Scan(src any) error {
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return errors.NewInternalf(errors.CodeInternal, "tried to scan from %T instead of string or bytes", src)
}
return json.Unmarshal(data, r)
}
// Value creates value to be stored in DB.
func (r *StorableAgentReport) Value() (driver.Value, error) {
if r == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "agent report is nil")
}
serialized, err := json.Marshal(r)
if err != nil {
return nil, errors.WrapInternalf(
err, errors.CodeInternal, "couldn't serialize agent report to JSON",
)
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytes
return string(serialized), nil
}

View File

@@ -0,0 +1,41 @@
package cloudintegrationtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
// CloudProviderType type alias.
type CloudProviderType struct{ valuer.String }
var (
// cloud providers.
CloudProviderTypeAWS = CloudProviderType{valuer.NewString("aws")}
CloudProviderTypeAzure = CloudProviderType{valuer.NewString("azure")}
// errors.
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("invalid_cloud_provider")
AWSIntegrationUserEmail = valuer.MustNewEmail("aws-integration@signoz.io")
AzureIntegrationUserEmail = valuer.MustNewEmail("azure-integration@signoz.io")
)
// CloudIntegrationUserEmails is the list of valid emails for Cloud One Click integrations.
// This is used for validation and restrictions in different contexts, across codebase.
var CloudIntegrationUserEmails = []valuer.Email{
AWSIntegrationUserEmail,
AzureIntegrationUserEmail,
}
// NewCloudProvider returns a new CloudProviderType from a string.
// It validates the input and returns an error if the input is not valid cloud provider.
func NewCloudProvider(provider string) (CloudProviderType, error) {
switch provider {
case CloudProviderTypeAWS.StringValue():
return CloudProviderTypeAWS, nil
case CloudProviderTypeAzure.StringValue():
return CloudProviderTypeAzure, nil
default:
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
}
}

View File

@@ -0,0 +1,104 @@
package cloudintegrationtypes
import "github.com/SigNoz/signoz/pkg/types/integrationtypes"
// request for creating connection artifact.
type (
PostableConnectionArtifact = ConnectionArtifactRequest
ConnectionArtifactRequest struct {
Aws *AWSConnectionArtifactRequest `json:"aws"`
}
AWSConnectionArtifactRequest struct {
DeploymentRegion string `json:"deploymentRegion"`
Regions []string `json:"regions"`
}
)
type (
// SignozCredentials is used to configure Agents to connect with Signoz
SignozCredentials struct {
SigNozAPIUrl string
SigNozAPIKey string // PAT
IngestionUrl string
IngestionKey string
}
ConnectionArtifact struct {
Aws *AWSConnectionArtifact `json:"aws"`
}
AWSConnectionArtifact struct {
ConnectionUrl string `json:"connectionURL"`
}
GettableConnectionArtifact = ConnectionArtifact
)
type (
AccountStatus struct {
Id string `json:"id"`
ProviderAccountId *string `json:"providerAccountID,omitempty"`
Status integrationtypes.AccountStatus `json:"status"`
}
GettableAccountStatus = AccountStatus
)
type (
AgentCheckInRequest struct {
// older backward compatible fields are mapped to new fields
// CloudIntegrationId string `json:"cloudIntegrationId"`
// AccountId string `json:"accountId"`
// New fields
ProviderAccountId string `json:"providerAccountId"`
CloudAccountId string `json:"cloudAccountId"`
Data map[string]any `json:"data,omitempty"`
}
PostableAgentCheckInRequest struct {
AgentCheckInRequest
// following are backward compatible fields for older running agents
// which gets mapped to new fields in AgentCheckInRequest
CloudIntegrationId string `json:"cloud_integration_id"`
CloudAccountId string `json:"cloud_account_id"`
}
GettableAgentCheckInResponse struct {
AgentCheckInResponse
CloudIntegrationId string `json:"cloud_integration_id"`
AccountId string `json:"account_id"`
}
AgentCheckInResponse struct {
// Older fields for backward compatibility are mapped to new fields below
// CloudIntegrationId string `json:"cloud_integration_id"`
// AccountId string `json:"account_id"`
// New fields
ProviderAccountId string `json:"providerAccountId"`
CloudAccountId string `json:"cloudAccountId"`
// IntegrationConfig populates data related to integration that is required for an agent
// to start collecting telemetry data
// keeping JSON key snake_case for backward compatibility
IntegrationConfig *IntegrationConfig `json:"integration_config,omitempty"`
}
IntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions"` // backward compatible
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty"` // backward compatible
// new fields
AWS *AWSIntegrationConfig `json:"aws,omitempty"`
}
AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions"`
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty"`
}
)

View File

@@ -0,0 +1,103 @@
package cloudintegrationtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
)
var (
CodeInvalidCloudRegion = errors.MustNewCode("invalid_cloud_region")
CodeMismatchCloudProvider = errors.MustNewCode("cloud_provider_mismatch")
)
// List of all valid cloud regions on Amazon Web Services.
var ValidAWSRegions = map[string]struct{}{
"af-south-1": {}, // Africa (Cape Town).
"ap-east-1": {}, // Asia Pacific (Hong Kong).
"ap-northeast-1": {}, // Asia Pacific (Tokyo).
"ap-northeast-2": {}, // Asia Pacific (Seoul).
"ap-northeast-3": {}, // Asia Pacific (Osaka).
"ap-south-1": {}, // Asia Pacific (Mumbai).
"ap-south-2": {}, // Asia Pacific (Hyderabad).
"ap-southeast-1": {}, // Asia Pacific (Singapore).
"ap-southeast-2": {}, // Asia Pacific (Sydney).
"ap-southeast-3": {}, // Asia Pacific (Jakarta).
"ap-southeast-4": {}, // Asia Pacific (Melbourne).
"ca-central-1": {}, // Canada (Central).
"ca-west-1": {}, // Canada West (Calgary).
"eu-central-1": {}, // Europe (Frankfurt).
"eu-central-2": {}, // Europe (Zurich).
"eu-north-1": {}, // Europe (Stockholm).
"eu-south-1": {}, // Europe (Milan).
"eu-south-2": {}, // Europe (Spain).
"eu-west-1": {}, // Europe (Ireland).
"eu-west-2": {}, // Europe (London).
"eu-west-3": {}, // Europe (Paris).
"il-central-1": {}, // Israel (Tel Aviv).
"me-central-1": {}, // Middle East (UAE).
"me-south-1": {}, // Middle East (Bahrain).
"sa-east-1": {}, // South America (Sao Paulo).
"us-east-1": {}, // US East (N. Virginia).
"us-east-2": {}, // US East (Ohio).
"us-west-1": {}, // US West (N. California).
"us-west-2": {}, // US West (Oregon).
}
// List of all valid cloud regions for Microsoft Azure.
var ValidAzureRegions = map[string]struct{}{
"australiacentral": {}, // Australia Central
"australiacentral2": {}, // Australia Central 2
"australiaeast": {}, // Australia East
"australiasoutheast": {}, // Australia Southeast
"austriaeast": {}, // Austria East
"belgiumcentral": {}, // Belgium Central
"brazilsouth": {}, // Brazil South
"brazilsoutheast": {}, // Brazil Southeast
"canadacentral": {}, // Canada Central
"canadaeast": {}, // Canada East
"centralindia": {}, // Central India
"centralus": {}, // Central US
"chilecentral": {}, // Chile Central
"denmarkeast": {}, // Denmark East
"eastasia": {}, // East Asia
"eastus": {}, // East US
"eastus2": {}, // East US 2
"francecentral": {}, // France Central
"francesouth": {}, // France South
"germanynorth": {}, // Germany North
"germanywestcentral": {}, // Germany West Central
"indonesiacentral": {}, // Indonesia Central
"israelcentral": {}, // Israel Central
"italynorth": {}, // Italy North
"japaneast": {}, // Japan East
"japanwest": {}, // Japan West
"koreacentral": {}, // Korea Central
"koreasouth": {}, // Korea South
"malaysiawest": {}, // Malaysia West
"mexicocentral": {}, // Mexico Central
"newzealandnorth": {}, // New Zealand North
"northcentralus": {}, // North Central US
"northeurope": {}, // North Europe
"norwayeast": {}, // Norway East
"norwaywest": {}, // Norway West
"polandcentral": {}, // Poland Central
"qatarcentral": {}, // Qatar Central
"southafricanorth": {}, // South Africa North
"southafricawest": {}, // South Africa West
"southcentralus": {}, // South Central US
"southindia": {}, // South India
"southeastasia": {}, // Southeast Asia
"spaincentral": {}, // Spain Central
"swedencentral": {}, // Sweden Central
"switzerlandnorth": {}, // Switzerland North
"switzerlandwest": {}, // Switzerland West
"uaecentral": {}, // UAE Central
"uaenorth": {}, // UAE North
"uksouth": {}, // UK South
"ukwest": {}, // UK West
"westcentralus": {}, // West Central US
"westeurope": {}, // West Europe
"westindia": {}, // West India
"westus": {}, // West US
"westus2": {}, // West US 2
"westus3": {}, // West US 3
}

View File

@@ -0,0 +1,213 @@
package cloudintegrationtypes
import (
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var S3Sync = valuer.NewString("s3sync")
type (
ServicesMetadata struct {
Services []*ServiceMetadata `json:"services"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
// As getting complete service definition is a heavy operation and the response is also large,
// initial integration page load can be very slow.
ServiceMetadata struct {
ServiceDefinitionMetadata
// if the service is enabled for the account
Enabled bool `json:"enabled"`
}
GettableServicesMetadata = ServicesMetadata
Service struct {
ServiceDefinition
ServiceConfig *ServiceConfig `json:"serviceConfig"`
}
GettableService = Service
UpdateServiceConfigRequest struct {
CloudIntegrationId valuer.UUID `json:"cloudIntegrationId"`
ServiceConfig *ServiceConfig `json:"serviceConfig"`
}
UpdateServiceConfigResponse struct {
Id string `json:"id"` // service id
CloudIntegrationId valuer.UUID `json:"cloudIntegrationId"`
ServiceConfig *ServiceConfig `json:"serviceConfig"`
}
)
type ServiceConfig struct {
AWS *AWSServiceConfig `json:"aws,omitempty"`
}
type AWSServiceConfig struct {
Logs *AWSServiceLogsConfig `json:"logs"`
Metrics *AWSServiceMetricsConfig `json:"metrics"`
}
// AWSServiceLogsConfig is AWS specific logs config for a service
// NOTE: the JSON keys are snake case for backward compatibility with existing agents.
type AWSServiceLogsConfig struct {
Enabled bool `json:"enabled"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"`
}
type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
// DefinitionMetadata represents service definition metadata. This is useful for showing service overview.
type ServiceDefinitionMetadata struct {
Id string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
}
type ServiceDefinition struct {
ServiceDefinitionMetadata
Overview string `json:"overview"` // markdown
Assets Assets `json:"assets"`
SupportedSignals SupportedSignals `json:"supported_signals"`
DataCollected DataCollected `json:"dataCollected"`
Strategy *CollectionStrategy `json:"telemetryCollectionStrategy"`
}
// CollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type CollectionStrategy struct {
AWS *AWSCollectionStrategy `json:"aws,omitempty"`
}
// Assets represents the collection of dashboards.
type Assets struct {
Dashboards []Dashboard `json:"dashboards"`
}
// SupportedSignals for cloud provider's service.
type SupportedSignals struct {
Logs bool `json:"logs"`
Metrics bool `json:"metrics"`
}
// DataCollected is curated static list of metrics and logs, this is shown as part of service overview.
type DataCollected struct {
Logs []CollectedLogAttribute `json:"logs"`
Metrics []CollectedMetric `json:"metrics"`
}
// CollectedLogAttribute represents a log attribute that is present in all log entries for a service,
// this is shown as part of service overview.
type CollectedLogAttribute struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
// CollectedMetric represents a metric that is collected for a service, this is shown as part of service overview.
type CollectedMetric struct {
Name string `json:"name"`
Type string `json:"type"`
Unit string `json:"unit"`
Description string `json:"description"`
}
// AWSCollectionStrategy represents signal collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSCollectionStrategy struct {
Metrics *AWSMetricsStrategy `json:"aws_metrics,omitempty"`
Logs *AWSLogsStrategy `json:"aws_logs,omitempty"`
S3Buckets map[string][]string `json:"s3_buckets,omitempty"` // Only available in S3 Sync Service Type in AWS
}
// AWSMetricsStrategy represents metrics collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSMetricsStrategy struct {
// to be used as https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-metricstream.html#cfn-cloudwatch-metricstream-includefilters
StreamFilters []struct {
// json tags here are in the shape expected by AWS API as detailed at
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cloudwatch-metricstream-metricstreamfilter.html
Namespace string `json:"Namespace"`
MetricNames []string `json:"MetricNames,omitempty"`
} `json:"cloudwatch_metric_stream_filters"`
}
// AWSLogsStrategy represents logs collection strategy for AWS services.
// this is AWS specific.
// NOTE: this structure is still using snake case, for backward compatibility,
// with existing agents.
type AWSLogsStrategy struct {
Subscriptions []struct {
// subscribe to all logs groups with specified prefix.
// eg: `/aws/rds/`
LogGroupNamePrefix string `json:"log_group_name_prefix"`
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
// "" implies no filtering is required.
FilterPattern string `json:"filter_pattern"`
} `json:"cloudwatch_logs_subscriptions"`
}
// Dashboard represents a dashboard definition for cloud integration.
// This is used to show available pre-made dashboards for a service,
// hence has additional fields like name and description and url for redirection to the right dashboard on click.
type Dashboard struct {
Id string `json:"id"`
Url string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
Image string `json:"image"`
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
// UTILS
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.
// This is used to generate unique dashboard ids for cloud integration, and also to parse the dashboard id to get the cloud provider and service id when needed.
func GetCloudIntegrationDashboardID(cloudProvider CloudProviderType, svcId, dashboardId string) string {
return fmt.Sprintf("cloud-integration--%s--%s--%s", cloudProvider, svcId, dashboardId)
}
// GetDashboardsFromAssets returns the list of dashboards for the cloud provider service from definition.
func GetDashboardsFromAssets(
svcId string,
orgID valuer.UUID,
cloudProvider CloudProviderType,
createdAt time.Time,
assets Assets,
) []*dashboardtypes.Dashboard {
dashboards := make([]*dashboardtypes.Dashboard, 0)
for _, d := range assets.Dashboards {
author := fmt.Sprintf("%s-integration", cloudProvider)
dashboards = append(dashboards, &dashboardtypes.Dashboard{
ID: GetCloudIntegrationDashboardID(cloudProvider, svcId, d.Id),
Locked: true,
OrgID: orgID,
Data: d.Definition,
TimeAuditable: types.TimeAuditable{
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
UserAuditable: types.UserAuditable{
CreatedBy: author,
UpdatedBy: author,
},
})
}
return dashboards
}

View File

@@ -0,0 +1,41 @@
package cloudintegrationtypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
// GetAccountByID returns a cloud integration account by id
GetAccountByID(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) (*StorableCloudIntegration, error)
// CreateAccount creates a new cloud integration account
CreateAccount(ctx context.Context, orgID valuer.UUID, account *StorableCloudIntegration) (*StorableCloudIntegration, error)
// UpdateAccount updates an existing cloud integration account
UpdateAccount(ctx context.Context, account *StorableCloudIntegration) error
// RemoveAccount marks a cloud integration account as removed by setting the RemovedAt field
RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) error
// GetConnectedAccounts returns all the cloud integration accounts for the org and cloud provider
GetConnectedAccounts(ctx context.Context, orgID valuer.UUID, provider CloudProviderType) ([]*StorableCloudIntegration, error)
// GetConnectedAccount for given provider
GetConnectedAccount(ctx context.Context, orgID valuer.UUID, provider CloudProviderType, providerAccountID string) (*StorableCloudIntegration, error)
// cloud_integration_service related methods
// GetServiceByType returns the cloud integration service for the given cloud integration id and service type
GetServiceByType(ctx context.Context, cloudIntegrationID valuer.UUID, serviceType string) (*StorableCloudIntegrationService, error)
// CreateService creates a new cloud integration service for the given cloud integration id and service type
CreateService(ctx context.Context, cloudIntegrationID valuer.UUID, service *StorableCloudIntegrationService) (*StorableCloudIntegrationService, error)
// UpdateService updates an existing cloud integration service for the given cloud integration id and service type
UpdateService(ctx context.Context, cloudIntegrationID valuer.UUID, service *StorableCloudIntegrationService) error
// GetServices returns all the cloud integration services for the given cloud integration id
GetServices(ctx context.Context, cloudIntegrationID valuer.UUID) ([]*StorableCloudIntegrationService, error)
}

View File

@@ -1,8 +1,10 @@
package zeustypes package zeustypes
import ( import (
"fmt"
"net/url" "net/url"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -35,6 +37,27 @@ type Host struct {
URL string `json:"url" required:"true"` URL string `json:"url" required:"true"`
} }
// GettableDeployment represents the parsed deployment info from zeus.GetDeployment.
type GettableDeployment struct {
Name string
SignozAPIUrl string
}
// NewGettableDeployment parses raw GetDeployment bytes into a GettableDeployment.
func NewGettableDeployment(data []byte) (*GettableDeployment, error) {
parsed := gjson.ParseBytes(data)
name := parsed.Get("name").String()
dns := parsed.Get("cluster.region.dns").String()
if name == "" || dns == "" {
return nil, errors.NewInternalf(errors.CodeInternal,
"deployment info response missing name or cluster region dns")
}
return &GettableDeployment{
Name: name,
SignozAPIUrl: fmt.Sprintf("https://%s.%s", name, dns),
}, nil
}
func NewGettableHost(data []byte) *GettableHost { func NewGettableHost(data []byte) *GettableHost {
parsed := gjson.ParseBytes(data) parsed := gjson.ParseBytes(data)
dns := parsed.Get("cluster.region.dns").String() dns := parsed.Get("cluster.region.dns").String()