Compare commits

..

41 Commits

Author SHA1 Message Date
swapnil-signoz
97d4f29d3a Merge branch 'feat/cloudintegration-azure-service-def' into feat/cloudintegration-azure-impl 2026-04-23 01:34:51 +05:30
swapnil-signoz
7aa720a6e1 Merge branch 'feat/cloudintegration-azure-types' into feat/cloudintegration-azure-service-def 2026-04-23 01:34:32 +05:30
swapnil-signoz
1296ae627d refactor: updating types 2026-04-23 01:34:10 +05:30
swapnil-signoz
4771391b9d Merge branch 'feat/cloudintegration-azure-impl' of https://github.com/SigNoz/signoz into feat/cloudintegration-azure-impl 2026-04-22 23:56:55 +05:30
swapnil-signoz
79e6485379 refactor: updating deny settings mode 2026-04-22 23:56:39 +05:30
swapnil-signoz
69f0508b15 Merge branch 'main' into feat/cloudintegration-azure-impl 2026-04-22 22:57:19 +05:30
swapnil-signoz
481e94e5d2 fix: update integration account ID and add agent version to Azure CLI and PowerShell commands 2026-04-22 19:33:20 +05:30
swapnil-signoz
4eadc17fd1 Merge branch 'feat/cloudintegration-azure-service-def' into feat/cloudintegration-azure-impl 2026-04-22 17:48:18 +05:30
swapnil-signoz
2acee1a8eb refactor: updating service defs 2026-04-22 17:47:34 +05:30
swapnil-signoz
f3c9129fa6 Merge branch 'feat/cloudintegration-azure-types' into feat/cloudintegration-azure-service-def 2026-04-22 17:43:16 +05:30
swapnil-signoz
3ab42a9012 refactor: update Azure service identifiers 2026-04-22 17:42:23 +05:30
swapnil-signoz
f870efbdac Merge branch 'feat/cloudintegration-azure-service-def' into feat/cloudintegration-azure-impl 2026-04-22 14:43:08 +05:30
swapnil-signoz
5068f412e6 Merge branch 'feat/cloudintegration-azure-types' into feat/cloudintegration-azure-service-def 2026-04-22 14:42:10 +05:30
swapnil-signoz
7f5d1d8ddb refactor: updating azure blob storage service name 2026-04-22 14:41:02 +05:30
swapnil-signoz
a81501bd87 refactor: updating blob storage service name 2026-04-22 14:39:37 +05:30
swapnil-signoz
06cc2b71c6 refactor: updating connection artifact struct 2026-04-22 14:28:05 +05:30
swapnil-signoz
2a23522510 Merge branch 'feat/cloudintegration-azure-service-def' into feat/cloudintegration-azure-impl 2026-04-22 13:58:58 +05:30
swapnil-signoz
f3dfff6de1 refactor: updating telemetry strategy 2026-04-22 13:58:16 +05:30
swapnil-signoz
841c928b90 Merge branch 'feat/cloudintegration-azure-types' into feat/cloudintegration-azure-service-def 2026-04-22 13:53:10 +05:30
swapnil-signoz
e3374d0bf3 refactor: updating strategy struct 2026-04-22 13:52:30 +05:30
swapnil-signoz
a7cd98dd8f feat: wip 2026-04-22 13:46:16 +05:30
swapnil-signoz
2714ded0b5 fix: handle optional connection URL in AWS integration 2026-04-22 01:45:56 +05:30
swapnil-signoz
c54513c327 Merge branch 'main' into feat/cloudintegration-azure-types 2026-04-22 01:44:25 +05:30
swapnil-signoz
7050a9a841 refactor: updating command key 2026-04-20 22:05:42 +05:30
swapnil-signoz
aea5447f55 Merge branch 'feat/cloudintegration-azure-types' into feat/cloudintegration-azure-service-def 2026-04-20 18:48:05 +05:30
swapnil-signoz
d65628d989 Merge branch 'main' into feat/cloudintegration-azure-types 2026-04-20 18:47:53 +05:30
swapnil-signoz
2d96ea84e5 Merge branch 'feat/cloudintegration-azure-types' into feat/cloudintegration-azure-service-def 2026-04-20 18:43:04 +05:30
swapnil-signoz
ff38502517 Merge branch 'main' into feat/cloudintegration-azure-types 2026-04-20 16:32:34 +05:30
swapnil-signoz
e50d0684b3 refactor: updating definitions with metrics and strategy 2026-04-20 15:01:39 +05:30
swapnil-signoz
6463029786 refactor: update service names for Azure Blob Storage telemetry 2026-04-20 15:01:39 +05:30
swapnil-signoz
806108d7b6 feat: adding service definitions for azure 2026-04-20 15:01:39 +05:30
swapnil-signoz
617afeb64b refactor: lint issues 2026-04-20 15:01:26 +05:30
swapnil-signoz
3737905670 feat: completing azure types 2026-04-20 14:37:49 +05:30
swapnil-signoz
54c79642f5 refactor: updating azure integration config 2026-04-20 11:19:41 +05:30
swapnil-signoz
0682c528da refactor: updating omitempty tags 2026-04-20 10:26:08 +05:30
swapnil-signoz
3377bc8a2b feat: adding azure services 2026-04-20 09:51:43 +05:30
swapnil-signoz
3b0d5dcf0e feat: adding cloud integration azure types 2026-04-19 19:20:06 +05:30
swapnil-signoz
aa8c4471dc refactor: using upper case key for AWS 2026-04-19 00:49:02 +05:30
swapnil-signoz
04b8ef4d86 refactor: separating cloud provider types 2026-04-19 00:24:13 +05:30
swapnil-signoz
9aee83607f Merge branch 'main' into refactor/cloudprovider-types-separation 2026-04-19 00:17:15 +05:30
swapnil-signoz
d8abbce47e refactor: moving types to cloud provider specific namespace/pkg 2026-04-17 11:57:41 +05:30
56 changed files with 1221 additions and 1178 deletions

View File

@@ -92,7 +92,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err

View File

@@ -137,12 +137,12 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, registry ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore)
if err != nil {
return nil, err
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, registry...), nil
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
@@ -167,7 +167,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
if err != nil {
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider()
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,

View File

@@ -668,8 +668,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
required:
- aws
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
type: object
CloudintegrationtypesAgentReport:
nullable: true
@@ -693,6 +693,90 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
deploymentRegion:
type: string
resourceGroups:
items:
type: string
type: array
required:
- deploymentRegion
- resourceGroups
type: object
CloudintegrationtypesAzureConnectionArtifact:
properties:
cliCommand:
type: string
cloudPowerShellCommand:
type: string
required:
- cliCommand
- cloudPowerShellCommand
type: object
CloudintegrationtypesAzureIntegrationConfig:
properties:
deploymentRegion:
type: string
resourceGroups:
items:
type: string
type: array
telemetryCollectionStrategy:
items:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: array
required:
- deploymentRegion
- resourceGroups
- telemetryCollectionStrategy
type: object
CloudintegrationtypesAzureLogsCollectionStrategy:
properties:
categoryGroups:
items:
type: string
type: array
required:
- categoryGroups
type: object
CloudintegrationtypesAzureMetricsCollectionStrategy:
type: object
CloudintegrationtypesAzureServiceConfig:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceLogsConfig'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceMetricsConfig'
required:
- logs
- metrics
type: object
CloudintegrationtypesAzureServiceLogsConfig:
properties:
enabled:
type: boolean
type: object
CloudintegrationtypesAzureServiceMetricsConfig:
properties:
enabled:
type: boolean
type: object
CloudintegrationtypesAzureTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAzureLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAzureMetricsCollectionStrategy'
resourceProvider:
type: string
resourceType:
type: string
required:
- resourceProvider
- resourceType
type: object
CloudintegrationtypesCloudIntegrationService:
nullable: true
properties:
@@ -737,8 +821,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
required:
- aws
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
type: object
CloudintegrationtypesCredentials:
properties:
@@ -910,8 +994,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
required:
- aws
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -934,8 +1018,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
required:
- aws
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
type: object
CloudintegrationtypesService:
properties:
@@ -972,8 +1056,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
required:
- aws
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
type: object
CloudintegrationtypesServiceID:
enum:
@@ -990,6 +1074,8 @@ components:
- s3sync
- sns
- sqs
- storageaccountsblob
- cdnprofile
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1018,16 +1104,32 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
required:
- aws
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
$ref: '#/components/schemas/CloudintegrationtypesAccountConfig'
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAccountConfig'
required:
- config
type: object
CloudintegrationtypesUpdatableAccountConfig:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
type: object
CloudintegrationtypesUpdatableAzureAccountConfig:
properties:
resourceGroups:
items:
type: string
type: array
required:
- resourceGroups
type: object
CloudintegrationtypesUpdatableService:
properties:
config:

View File

@@ -2,27 +2,47 @@ package implcloudprovider
import (
"context"
"sort"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type azurecloudprovider struct{}
type azurecloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewAzureCloudProvider() cloudintegration.CloudProviderModule {
return &azurecloudprovider{}
func NewAzureCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &azurecloudprovider{
serviceDefinitions: defStore,
}
}
func (provider *azurecloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
panic("implement me")
cliCommand := cloudintegrationtypes.NewAzureConnectionCLICommand(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.Azure)
psCommand := cloudintegrationtypes.NewAzureConnectionPowerShellCommand(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.Azure)
return &cloudintegrationtypes.ConnectionArtifact{
Azure: cloudintegrationtypes.NewAzureConnectionArtifact(cliCommand, psCommand),
}, nil
}
func (provider *azurecloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
return provider.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeAzure)
}
func (provider *azurecloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
panic("implement me")
serviceDef, err := provider.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeAzure, serviceID)
if err != nil {
return nil, err
}
// override cloud integration dashboard id.
for index, dashboard := range serviceDef.Assets.Dashboards {
serviceDef.Assets.Dashboards[index].ID = cloudintegrationtypes.GetCloudIntegrationDashboardID(cloudintegrationtypes.CloudProviderTypeAzure, serviceID.StringValue(), dashboard.ID)
}
return serviceDef, nil
}
func (provider *azurecloudprovider) BuildIntegrationConfig(
@@ -30,5 +50,56 @@ func (provider *azurecloudprovider) BuildIntegrationConfig(
account *cloudintegrationtypes.Account,
services []*cloudintegrationtypes.StorableCloudIntegrationService,
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
panic("implement me")
sort.Slice(services, func(i, j int) bool {
return services[i].Type.StringValue() < services[j].Type.StringValue()
})
var strategies []*cloudintegrationtypes.AzureTelemetryCollectionStrategy
for _, storedSvc := range services {
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeAzure, storedSvc.Config)
if err != nil {
return nil, err
}
svcDef, err := provider.GetServiceDefinition(ctx, storedSvc.Type)
if err != nil {
return nil, err
}
strategy := svcDef.TelemetryCollectionStrategy.Azure
if strategy == nil {
continue
}
logsEnabled := svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeAzure)
metricsEnabled := svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeAzure)
if !logsEnabled && !metricsEnabled {
continue
}
entry := &cloudintegrationtypes.AzureTelemetryCollectionStrategy{
ResourceProvider: strategy.ResourceProvider,
ResourceType: strategy.ResourceType,
}
if metricsEnabled && strategy.Metrics != nil {
entry.Metrics = strategy.Metrics
}
if logsEnabled && strategy.Logs != nil {
entry.Logs = strategy.Logs
}
strategies = append(strategies, entry)
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
Azure: cloudintegrationtypes.NewAzureIntegrationConfig(
account.Config.Azure.DeploymentRegion,
account.Config.Azure.ResourceGroups,
strategies,
),
}, nil
}

View File

@@ -429,9 +429,13 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
stats["cloudintegration.aws.connectedaccounts.count"] = awsAccountsCount
}
// NOTE: not adding stats for services for now.
// get connected accounts for Azure
azureAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeAzure)
if err == nil {
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
}
// TODO: add more cloud providers when supported
// NOTE: not adding stats for services for now.
return stats, nil
}

View File

@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/url"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
@@ -38,7 +37,6 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
providerSettings.MeterProvider,
client.WithRequestResponseLog(true),
client.WithRetryCount(3),
client.WithTimeout(30*time.Second),
)
if err != nil {
return nil, err

View File

@@ -52,7 +52,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.10",
"@signozhq/ui": "0.0.9",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -266,4 +266,4 @@
"tmp": "0.2.4",
"vite": "npm:rolldown-vite@7.3.1"
}
}
}

View File

@@ -795,7 +795,8 @@ export interface CloudintegrationtypesAccountDTO {
}
export interface CloudintegrationtypesAccountConfigDTO {
aws: CloudintegrationtypesAWSAccountConfigDTO;
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
}
/**
@@ -829,6 +830,86 @@ export interface CloudintegrationtypesAssetsDTO {
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
export interface CloudintegrationtypesAzureAccountConfigDTO {
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
resourceGroups: string[];
}
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
/**
* @type string
*/
cliCommand: string;
/**
* @type string
*/
cloudPowerShellCommand: string;
}
export interface CloudintegrationtypesAzureIntegrationConfigDTO {
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
resourceGroups: string[];
/**
* @type array
*/
telemetryCollectionStrategy: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO[];
}
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
/**
* @type array
*/
categoryGroups: string[];
}
export interface CloudintegrationtypesAzureMetricsCollectionStrategyDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesAzureServiceConfigDTO {
logs: CloudintegrationtypesAzureServiceLogsConfigDTO;
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesAzureServiceLogsConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface CloudintegrationtypesAzureServiceMetricsConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface CloudintegrationtypesAzureTelemetryCollectionStrategyDTO {
logs?: CloudintegrationtypesAzureLogsCollectionStrategyDTO;
metrics?: CloudintegrationtypesAzureMetricsCollectionStrategyDTO;
/**
* @type string
*/
resourceProvider: string;
/**
* @type string
*/
resourceType: string;
}
/**
* @nullable
*/
@@ -890,7 +971,8 @@ export interface CloudintegrationtypesCollectedMetricDTO {
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
@@ -1074,7 +1156,8 @@ export interface CloudintegrationtypesPostableAccountDTO {
}
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws: CloudintegrationtypesAWSPostableAccountConfigDTO;
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
}
/**
@@ -1109,7 +1192,8 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
}
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws: CloudintegrationtypesAWSIntegrationConfigDTO;
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
}
export interface CloudintegrationtypesServiceDTO {
@@ -1137,7 +1221,8 @@ export interface CloudintegrationtypesServiceDTO {
}
export interface CloudintegrationtypesServiceConfigDTO {
aws: CloudintegrationtypesAWSServiceConfigDTO;
aws?: CloudintegrationtypesAWSServiceConfigDTO;
azure?: CloudintegrationtypesAzureServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
@@ -1154,6 +1239,8 @@ export enum CloudintegrationtypesServiceIDDTO {
s3sync = 's3sync',
sns = 'sns',
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
}
export interface CloudintegrationtypesServiceMetadataDTO {
/**
@@ -1186,11 +1273,24 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
config: CloudintegrationtypesAccountConfigDTO;
config: CloudintegrationtypesUpdatableAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
/**
* @type array
*/
resourceGroups: string[];
}
export interface CloudintegrationtypesUpdatableServiceDTO {

View File

@@ -37,7 +37,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -47,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
],
);

View File

@@ -25,8 +25,6 @@ export default function ChartWrapper({
showTooltip = true,
showLegend = true,
canPinTooltip = false,
pinKey,
onClick,
syncMode,
syncKey,
onDestroy = noop,
@@ -103,8 +101,6 @@ export default function ChartWrapper({
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onClick}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,

View File

@@ -26,11 +26,10 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
...props,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -21,17 +21,10 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
[
customTooltip,
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -13,12 +13,6 @@ interface BaseChartProps {
showTooltip?: boolean;
showLegend?: boolean;
canPinTooltip?: boolean;
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;

View File

@@ -121,7 +121,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
canPinTooltip
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
data={chartData as uPlot.AlignedData}

View File

@@ -89,7 +89,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
canPinTooltip
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}

View File

@@ -112,7 +112,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
canPinTooltip
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}

View File

@@ -164,7 +164,8 @@ export function useIntegrationModal({
{
onSuccess: (response: CreateAccountMutationResult) => {
const accountId = response.data.id;
const connectionUrl = response.data.connectionArtifact.aws.connectionUrl;
const connectionUrl =
response.data.connectionArtifact.aws?.connectionUrl ?? '';
logEvent(
'AWS Integration: Account connection attempt redirected to AWS',

View File

@@ -1,22 +1,73 @@
.container {
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
color: var(--bg-vanilla-100);
border-radius: 6px;
border: 1px solid var(--l2-border);
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.pinned {
border-color: var(--ring);
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
.uplot-tooltip-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}
.uplot-tooltip-divider {
background-color: var(--bg-vanilla-300);
}
}
.divider {
.uplot-tooltip-header-container {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: 8px;
&:last-child {
padding-bottom: 1rem;
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
}
.uplot-tooltip-divider {
width: 100%;
height: 1px;
background-color: var(--l2-border);
background-color: var(--bg-ink-100);
}
.uplot-tooltip-list {
// Virtuoso absolutely positions its item rows; left: 0 prevents accidental
// horizontal offset when the scroller has padding or transform applied.
div[data-viewport-type='element'] {
left: 0;
box-sizing: border-box;
padding: 4px 12px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
}

View File

@@ -1,28 +1,71 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTimezone } from 'providers/Timezone';
import { TooltipProps } from '../types';
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
import TooltipList from './components/TooltipList/TooltipList';
import TooltipItem from './components/TooltipItem/TooltipItem';
import Styles from './Tooltip.module.scss';
// Fallback per-item height used for the initial size estimate before
// Virtuoso reports the real total height via totalListHeightChanged.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
export default function Tooltip({
uPlotInstance,
timezone,
content,
showTooltipHeader = true,
isPinned,
canPinTooltip,
dismiss,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone: userTimezone } = useTimezone();
const [totalListHeight, setTotalListHeight] = useState(0);
const tooltipContent = useMemo(() => content ?? [], [content]);
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
const activeItem = useMemo(
() => tooltipContent.find((item) => item.isActive) ?? null,
[tooltipContent],
);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on the first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const virtuosoHeight = useMemo(() => {
return totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(tooltipContent.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT);
}, [totalListHeight, tooltipContent.length]);
const showHeader = showTooltipHeader || activeItem != null;
// With a single series the active item is fully represented in the header —
// hide the divider and list to avoid showing a duplicate row.
@@ -31,24 +74,46 @@ export default function Tooltip({
return (
<div
className={cx(Styles.container, isPinned && Styles.pinned)}
className={cx(Styles.uplotTooltipContainer, !isDarkMode && Styles.lightMode)}
data-testid="uplot-tooltip-container"
>
{showHeader && (
<TooltipHeader
uPlotInstance={uPlotInstance}
timezone={timezone}
showTooltipHeader={showTooltipHeader}
isPinned={isPinned}
activeItem={activeItem}
/>
<div className={Styles.uplotTooltipHeaderContainer}>
{showTooltipHeader && headerTitle && (
<div
className={Styles.uplotTooltipHeader}
data-testid="uplot-tooltip-header"
>
<span>{headerTitle}</span>
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
</div>
)}
{showDivider && <span className={Styles.divider} />}
{showDivider && <span className={Styles.uplotTooltipDivider} />}
{showList && <TooltipList content={tooltipContent} />}
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
{showList && (
<Virtuoso
className={Styles.uplotTooltipList}
data-testid="uplot-tooltip-list"
data={tooltipContent}
style={{ height: virtuosoHeight, width: '100%' }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import userEvent from '@testing-library/user-event';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -93,6 +92,7 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
clickData: null,
} as TooltipTestProps;
return render(
@@ -191,85 +191,3 @@ describe('Tooltip', () => {
expect(list).toHaveStyle({ height: '76px' });
});
});
describe('Tooltip footer hint', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('to pin the tooltip');
});
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
const footer = screen.getByTestId('uplot-tooltip-footer');
expect(footer).toHaveTextContent('Press');
expect(footer).toHaveTextContent('P');
expect(footer).toHaveTextContent('Esc');
expect(footer).toHaveTextContent('to unpin');
});
it('does not render Unpin button when not pinned', () => {
renderTooltip({ isPinned: false, canPinTooltip: true });
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
it('renders Unpin button when pinned', () => {
renderTooltip({ isPinned: true, canPinTooltip: true });
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
expect(unpinBtn).toBeInTheDocument();
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
});
it('calls dismiss when Unpin button is clicked', async () => {
const dismiss = jest.fn();
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
const user = userEvent.setup();
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
await user.click(unpinBtn);
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('footer has role="status" for screen reader announcements', () => {
renderTooltip({ canPinTooltip: true });
const footer = screen.getByRole('status');
expect(footer).toBeInTheDocument();
});
});
describe('Tooltip header status pill', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
});
it('shows Pinned status when pinned and header is visible', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, isPinned: true });
expect(screen.getByText('Pinned')).toBeInTheDocument();
});
it('does not render status pill when showTooltipHeader is false', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, showTooltipHeader: false, isPinned: false });
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
});
});

View File

@@ -1,18 +0,0 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 12px;
border-top: 1px solid var(--l2-border);
background: var(--l2-background);
border-radius: 0 0 6px 6px;
}
.hint {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--l2-foreground);
}

View File

@@ -1,58 +0,0 @@
import { Button } from '@signozhq/ui';
import { Kbd } from '@signozhq/ui';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { X } from 'lucide-react';
import Styles from './TooltipFooter.module.scss';
interface TooltipFooterProps {
pinKey?: string;
isPinned: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
dismiss,
}: TooltipFooterProps): JSX.Element {
return (
<div
className={Styles.footer}
role="status"
data-testid="uplot-tooltip-footer"
>
<div className={Styles.hint}>
{isPinned ? (
<>
<span>Press</span>
<Kbd active>{pinKey.toUpperCase()}</Kbd>
<span>or</span>
<Kbd active>Esc</Kbd>
<span>to unpin</span>
</>
) : (
<>
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>
<span>to pin the tooltip</span>
</>
)}
</div>
{isPinned && (
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={dismiss}
aria-label="Unpin tooltip"
data-testid="uplot-tooltip-unpin"
>
<X size={10} />
<span>Unpin</span>
</Button>
)}
</div>
);
}

View File

@@ -1,32 +0,0 @@
.headerContainer {
padding: 1rem 1rem 0 1rem;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
&:last-child {
padding-bottom: var(--spacing-4);
}
}
.headerRow {
font-size: var(--font-size-sm);
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -1,85 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Pin } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import Styles from './TooltipHeader.module.scss';
interface TooltipHeaderProps {
uPlotInstance: uPlot;
timezone?: Timezone;
showTooltipHeader: boolean;
isPinned: boolean;
activeItem: TooltipContentItem | null;
}
export default function TooltipHeader({
uPlotInstance,
timezone,
showTooltipHeader,
isPinned,
activeItem,
}: TooltipHeaderProps): JSX.Element {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
const timestamp = uPlotInstance.data[0]?.[cursorIdx];
if (timestamp == null) {
return null;
}
return dayjs(timestamp * 1000)
.tz(resolvedTimezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.cursor.idx,
showTooltipHeader,
]);
return (
<div
className={Styles.headerContainer}
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && headerTitle && (
<div className={Styles.headerRow}>
<span>{headerTitle}</span>
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
<>
<Pin size={12} />
<span>Pinned</span>
</>
</div>
)}
</div>
)}
{activeItem && (
<TooltipItem
item={activeItem}
isItemActive={true}
containerTestId="uplot-tooltip-pinned"
markerTestId="uplot-tooltip-pinned-marker"
contentTestId="uplot-tooltip-pinned-content"
/>
)}
</div>
);
}

View File

@@ -1,27 +0,0 @@
.list {
width: 100%;
:global(div[data-viewport-type='element']) {
left: 0;
box-sizing: border-box;
padding: 4px 12px 4px 16px;
}
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-100);
border-radius: 0.5rem;
}
}
.listLightMode {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-400);
}
}

View File

@@ -1,48 +0,0 @@
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import Styles from './TooltipList.module.scss';
// Fallback per-item height before Virtuoso reports the real total.
const TOOLTIP_ITEM_HEIGHT = 38;
const LIST_MAX_HEIGHT = 300;
interface TooltipListProps {
content: TooltipContentItem[];
}
export default function TooltipList({
content,
}: TooltipListProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [totalListHeight, setTotalListHeight] = useState(0);
// Use the measured height from Virtuoso when available; fall back to a
// per-item estimate on first render. Math.ceil prevents a 1 px
// subpixel rounding gap from triggering a spurious scrollbar.
const height = useMemo(
() =>
totalListHeight > 0
? Math.ceil(Math.min(totalListHeight, LIST_MAX_HEIGHT))
: Math.min(content.length * TOOLTIP_ITEM_HEIGHT, LIST_MAX_HEIGHT),
[totalListHeight, content.length],
);
return (
<Virtuoso
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
data-testid="uplot-tooltip-list"
data={content}
style={{ height }}
totalListHeightChanged={setTotalListHeight}
itemContent={(_, item): JSX.Element => (
<TooltipItem item={item} isItemActive={false} />
)}
/>
);
}

View File

@@ -62,7 +62,6 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];

View File

@@ -1,4 +1,4 @@
.tooltipPluginContainer {
.tooltip-plugin-container {
top: 0;
left: 0;
width: 100%;
@@ -10,9 +10,13 @@
transform: translate(-1000px, -1000px); // hide the tooltip initially
opacity: 0;
pointer-events: none;
}
.visible {
opacity: 1;
pointer-events: all;
&.pinned {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&.visible {
opacity: 1;
pointer-events: all;
}
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
@@ -16,21 +17,18 @@ import {
} from './tooltipController';
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
TooltipClickData,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
TooltipPluginProps,
TooltipViewState,
} from './types';
import {
buildClickData,
createInitialViewState,
createLayoutObserver,
} from './utils';
import { createInitialViewState, createLayoutObserver } from './utils';
import Styles from './TooltipPlugin.module.scss';
import './TooltipPlugin.styles.scss';
const INTERACTIVE_CONTAINER_CLASSNAME = '.tooltip-plugin-container';
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
// the plot this avoids flicker when moving between nearby points.
const HOVER_DISMISS_DELAY_MS = 100;
@@ -46,8 +44,6 @@ export default function TooltipPlugin({
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
onClick,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const rafId = useRef<number | null>(null);
@@ -135,8 +131,8 @@ export default function TooltipPlugin({
// Dismiss the tooltip when the user clicks / presses a key
// outside the tooltip container while it is pinned.
const onOutsideInteraction = (event: Event): void => {
const target = event.target as Node;
if (!containerRef.current?.contains(target)) {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_CONTAINER_CLASSNAME)) {
dismissTooltip();
}
};
@@ -163,14 +159,13 @@ export default function TooltipPlugin({
// Attach / detach global listeners when pin state changes so
// we can detect when the user interacts outside the tooltip.
// Keyboard unpinning is handled exclusively in handleKeyDown so
// that only P (toggle) and Escape (release) can dismiss — not
// arbitrary keystrokes like arrow keys or Tab.
function toggleOutsideListeners(enable: boolean): void {
if (enable) {
document.addEventListener('mousedown', onOutsideInteraction, true);
document.addEventListener('keydown', onOutsideInteraction, true);
} else {
document.removeEventListener('mousedown', onOutsideInteraction, true);
document.removeEventListener('keydown', onOutsideInteraction, true);
}
}
@@ -288,84 +283,66 @@ export default function TooltipPlugin({
}
};
// Handles all tooltip-pin keyboard interactions:
// Escape — always releases the tooltip when pinned (never steals Escape
// from other handlers since we do not call stopPropagation).
// pinKey — toggles: pins when hovering+unpinned, unpins when pinned.
const handleKeyDown = (event: KeyboardEvent): void => {
// Escape: release-only (never toggles on).
if (event.key === 'Escape') {
if (controller.pinned) {
dismissTooltip();
}
return;
}
if (event.key.toLowerCase() !== pinKey.toLowerCase()) {
return;
}
// Toggle off: P pressed while already pinned.
if (controller.pinned) {
dismissTooltip();
return;
}
// Toggle on: P pressed while hovering.
// When pinning is enabled, a click on the plot overlay while
// hovering converts the transient tooltip into a pinned one.
// Uses getPlot(controller) to avoid closing over u (plot), which
// would retain the plot and detached canvases across unmounts.
const handleUPlotOverClick = (event: MouseEvent): void => {
const plot = getPlot(controller);
if (
!plot ||
!controller.hoverActive ||
controller.focusedSeriesIndex == null
) {
return;
}
const cursorLeft = plot.cursor.left ?? -1;
const cursorTop = plot.cursor.top ?? -1;
if (cursorLeft < 0 || cursorTop < 0) {
return;
}
const plotRect = plot.over.getBoundingClientRect();
const syntheticEvent = ({
clientX: plotRect.left + cursorLeft,
clientY: plotRect.top + cursorTop,
target: plot.over,
offsetX: cursorLeft,
offsetY: cursorTop,
} as unknown) as MouseEvent;
controller.clickData = buildClickData(syntheticEvent, plot);
controller.pinned = true;
scheduleRender(true);
};
// Forward overlay clicks to the consumer-provided onClick callback.
const handleOverClick = (event: MouseEvent): void => {
const plot = getPlot(controller);
/**
* Only trigger onClick if the click happened on the plot overlay and there is a focused series.
* It also ensures that clicks only trigger onClick when there is a relevant data point (i.e. a focused series) to provide context for the click.
*/
if (
plot &&
event.target === plot.over &&
controller.hoverActive &&
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
const clickData = buildClickData(event, plot);
onClick?.(clickData);
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
let clickedDataTimestamp = xValue;
if (focusedSeries) {
const dataIndex = plot.posToIdx(event.offsetX);
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
}
const clickData: TooltipClickData = {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
controller.clickData = clickData;
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
}, 0);
}
};
let overClickHandler: ((event: MouseEvent) => void) | null = null;
// Called once per uPlot instance; used to store the instance on the controller.
// Called once per uPlot instance; used to store the instance
// on the controller and optionally attach the pinning handler.
const handleInit = (u: uPlot): void => {
controller.plot = u;
updateState({ hasPlot: true });
if (onClick) {
overClickHandler = handleOverClick;
if (canPinTooltip) {
overClickHandler = handleUPlotOverClick;
u.over.addEventListener('click', overClickHandler);
}
};
@@ -412,18 +389,13 @@ export default function TooltipPlugin({
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleScroll, true);
if (canPinTooltip) {
document.addEventListener('keydown', handleKeyDown, true);
}
return (): void => {
layoutRef.current?.observer.disconnect();
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener('mousedown', onOutsideInteraction, true);
if (canPinTooltip) {
document.removeEventListener('keydown', handleKeyDown, true);
}
document.removeEventListener('keydown', onOutsideInteraction, true);
cancelPendingRender();
removeReadyHook();
removeInitHook();
@@ -433,7 +405,9 @@ export default function TooltipPlugin({
removeSetCursorHook();
if (overClickHandler) {
const plot = getPlot(controller);
plot?.over.removeEventListener('click', overClickHandler);
if (plot) {
plot.over.removeEventListener('click', overClickHandler);
}
overClickHandler = null;
}
clearPlotReferences();
@@ -473,12 +447,8 @@ export default function TooltipPlugin({
}, [isHovering, hasPlot]);
const tooltipBody = useMemo(() => {
if (isPinned) {
if (pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
// No custom pinned element — keep showing the last hover contents.
return contents ?? null;
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
if (isHovering) {
@@ -501,8 +471,9 @@ export default function TooltipPlugin({
return createPortal(
<div
className={cx(Styles.tooltipPluginContainer, {
[Styles.visible]: isTooltipVisible,
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isTooltipVisible,
})}
style={{
...style,
@@ -513,7 +484,6 @@ export default function TooltipPlugin({
aria-atomic="true"
aria-hidden={!isTooltipVisible}
ref={containerRef}
data-pinned={isPinned}
data-testid="tooltip-plugin-container"
>
{tooltipBody}

View File

@@ -102,12 +102,6 @@ export function updateHoverState(
controller: TooltipControllerState,
syncTooltipWithDashboard: boolean,
): void {
// When pinned, keep hoverActive stable so the tooltip stays visible
// until explicitly dismissed — the cursor lock fires asynchronously
// and setSeries/setLegend can otherwise race and clear hoverActive.
if (controller.pinned) {
return;
}
// When the cursor is driven by dashboardlevel sync, we only show
// the tooltip if the plot is in viewport and at least one series
// is active. Otherwise we fall back to local interaction logic.

View File

@@ -11,9 +11,6 @@ import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
export const TOOLTIP_OFFSET = 10;
// Default key that pins the tooltip while hovering over the chart.
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
export enum DashboardCursorSync {
Crosshair,
None,
@@ -44,10 +41,6 @@ export interface TooltipSyncMetadata {
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
pinKey?: string;
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
onClick?: (clickData: TooltipClickData) => void;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;

View File

@@ -1,11 +1,4 @@
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
TOOLTIP_OFFSET,
TooltipClickData,
TooltipLayoutInfo,
TooltipViewState,
} from './types';
import { TOOLTIP_OFFSET, TooltipLayoutInfo, TooltipViewState } from './types';
export function isPlotInViewport(
rect: uPlot.BBox,
@@ -165,40 +158,3 @@ export function createLayoutObserver(
};
return layout;
}
/**
* Resolves a TooltipClickData snapshot from a MouseEvent (real or synthetic)
* and the current uPlot instance. Shared by the overlay click handler and the
* keyboard-pin handler (which synthesises an event from the cursor position).
*/
export function buildClickData(
event: MouseEvent,
plot: uPlot,
): TooltipClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
let clickedDataTimestamp = xValue;
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
return {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
}

View File

@@ -7,10 +7,7 @@ import type uPlot from 'uplot';
import { TooltipRenderArgs } from '../../components/types';
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import {
DashboardCursorSync,
DEFAULT_PIN_TOOLTIP_KEY,
} from '../TooltipPlugin/types';
import { DashboardCursorSync } from '../TooltipPlugin/types';
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
// We only care that pinning logic runs without throwing, not which series is focused.
@@ -63,7 +60,7 @@ function getHandler(config: ConfigMock, hookName: string): HookHandler {
function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown>; left: number; top: number };
cursor: { event: Record<string, unknown> };
posToVal: jest.Mock<number, [value: number]>;
posToIdx: jest.Mock<number, []>;
data: [number[], number[]];
@@ -74,9 +71,7 @@ function createFakePlot(): {
return {
over,
setCursor: jest.fn(),
// left / top are set to valid values so keyboard-pin tests do not
// hit the "cursor off-screen" guard inside handleKeyDown.
cursor: { event: {}, left: 50, top: 50 },
cursor: { event: {} },
// In real uPlot these map overlay coordinates to data-space values.
posToVal: jest.fn((value: number) => value),
posToIdx: jest.fn(() => 0),
@@ -149,7 +144,7 @@ describe('TooltipPlugin', () => {
}),
);
expect(screen.queryByTestId('tooltip-plugin-container')).toBeNull();
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
});
it('registers all required uPlot hooks on mount', () => {
@@ -187,7 +182,9 @@ describe('TooltipPlugin', () => {
expect(renderTooltip).toHaveBeenCalled();
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
const container = screen.getByTestId('tooltip-plugin-container');
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container).not.toBeNull();
expect(container.parentElement).toBe(document.body);
});
@@ -206,7 +203,9 @@ describe('TooltipPlugin', () => {
renderAndActivateHover(config);
const container = screen.getByTestId('tooltip-plugin-container');
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container.parentElement).toBe(document.body);
const fullscreenRoot = document.createElement('div');
@@ -246,27 +245,24 @@ describe('TooltipPlugin', () => {
// ---- Pin behaviour ----------------------------------------------------------
describe('pin behaviour', () => {
it('pins the tooltip when canPinTooltip is true and the pinKey is pressed while hovering', () => {
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
const fakePlot = renderAndActivateHover(config, undefined, {
canPinTooltip: true,
});
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
return waitFor(() => {
const updated = screen.getByTestId('tooltip-plugin-container');
expect(updated).toBeInTheDocument();
expect(updated.getAttribute('data-pinned') === 'true').toBe(true);
expect(updated.classList.contains('pinned')).toBe(true);
});
});
@@ -276,7 +272,7 @@ describe('TooltipPlugin', () => {
React.createElement('div', null, 'pinned-tooltip'),
);
renderAndActivateHover(
const fakePlot = renderAndActivateHover(
config,
() => React.createElement('div', null, 'hover-tooltip'),
{
@@ -288,12 +284,7 @@ describe('TooltipPlugin', () => {
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await waitFor(() => {
@@ -327,20 +318,18 @@ describe('TooltipPlugin', () => {
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
// Pin the tooltip via the keyboard shortcut.
// Pin the tooltip.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
// Wait until the tooltip is actually pinned.
// Wait until the tooltip is actually pinned (pointer events enabled)
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(true);
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement | null;
expect(container).not.toBeNull();
expect(container?.classList.contains('pinned')).toBe(true);
});
const button = await screen.findByRole('button', { name: 'Dismiss' });
@@ -353,7 +342,8 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
expect(container.textContent).toBe('');
});
});
@@ -379,21 +369,16 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
// Pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
(document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement)?.classList.contains('pinned'),
).toBe(true);
// Simulate data update should dismiss the pinned tooltip.
@@ -405,7 +390,8 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
jest.useRealTimers();
});
@@ -431,21 +417,15 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
).toBe(true);
// Click outside the tooltip container.
@@ -459,13 +439,14 @@ describe('TooltipPlugin', () => {
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip when Escape is pressed while pinned', async () => {
it('unpins the tooltip on outside keydown', async () => {
jest.useFakeTimers();
const config = createConfigMock();
@@ -486,24 +467,18 @@ describe('TooltipPlugin', () => {
jest.runAllTimers();
});
// Pin via keyboard.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
jest.runAllTimers();
});
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
document
.querySelector('.tooltip-plugin-container')
?.classList.contains('pinned'),
).toBe(true);
// Press Escape to release.
// Press a key outside the tooltip.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
@@ -515,282 +490,12 @@ describe('TooltipPlugin', () => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container).toBeInTheDocument();
expect(container.getAttribute('aria-hidden')).toBe('true');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.classList.contains('visible')).toBe(false);
expect(container.classList.contains('pinned')).toBe(false);
});
jest.useRealTimers();
});
it('unpins the tooltip when the pin key is pressed a second time (toggle off)', async () => {
jest.useFakeTimers();
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
jest.runAllTimers();
// First press — pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
// Second press — unpin (toggle off).
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
jest.useRealTimers();
});
it('does not unpin on Escape when tooltip is not pinned', () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
// Escape without pinning first — should be a no-op.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
});
const container = screen.getByTestId('tooltip-plugin-container');
// Tooltip should still be hovering (visible), not dismissed.
expect(container.getAttribute('aria-hidden')).toBe('false');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
it('does not unpin on arbitrary keys that are not Escape or the pin key', async () => {
jest.useFakeTimers();
const config = createConfigMock();
renderAndActivateHover(config, undefined, { canPinTooltip: true });
jest.runAllTimers();
// Pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
// Arrow key — should NOT unpin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
jest.runAllTimers();
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
jest.useRealTimers();
});
});
// ---- Keyboard pin edge cases ------------------------------------------------
describe('keyboard pin edge cases', () => {
it('does not pin when cursor coordinates are negative (cursor off-screen)', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
// Negative cursor coords — handleKeyDown bails out before pinning.
const fakePlot = {
...createFakePlot(),
cursor: { event: {}, left: -1, top: -1 },
};
act(() => {
getHandler(config, 'init')(fakePlot);
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
});
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
});
it('does not pin when hover is not active', () => {
const config = createConfigMock();
render(
React.createElement(TooltipPlugin, {
config,
render: () => React.createElement('div', null, 'tooltip-body'),
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const fakePlot = createFakePlot();
act(() => {
// Initialise the plot but do NOT call setSeries hoverActive stays false.
getHandler(config, 'init')(fakePlot);
});
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: DEFAULT_PIN_TOOLTIP_KEY,
bubbles: true,
}),
);
});
// The container exists once the plot is initialised, but it should
// be hidden and not pinned since hover was never activated.
const container = screen.getByTestId('tooltip-plugin-container');
expect(container.getAttribute('data-pinned') === 'true').toBe(false);
expect(container.getAttribute('aria-hidden')).toBe('true');
});
it('ignores other keys and only pins on the configured pinKey', async () => {
const config = createConfigMock();
renderAndActivateHover(config, undefined, {
canPinTooltip: true,
pinKey: 'p',
});
// 'l' should NOT pin when pinKey is 'p'.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'l',
bubbles: true,
}),
);
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(false);
});
// Custom pin key 'p' SHOULD pin.
act(() => {
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'p', bubbles: true }),
);
});
await waitFor(() => {
expect(
screen
.getByTestId('tooltip-plugin-container')
.getAttribute('data-pinned') === 'true',
).toBe(true);
});
});
it('does not register a keydown listener when canPinTooltip is false', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(document, 'addEventListener');
render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
canPinTooltip: false,
}),
);
const keydownCalls = addSpy.mock.calls.filter(
([type]) => type === 'keydown',
);
expect(keydownCalls).toHaveLength(0);
});
it('removes the keydown pin listener on unmount', () => {
const config = createConfigMock();
const addSpy = jest.spyOn(document, 'addEventListener');
const removeSpy = jest.spyOn(document, 'removeEventListener');
const { unmount } = render(
React.createElement(TooltipPlugin, {
config,
render: () => null,
syncMode: DashboardCursorSync.None,
canPinTooltip: true,
}),
);
const pinListenerCall = addSpy.mock.calls.find(
([type]) => type === 'keydown',
);
expect(pinListenerCall).toBeDefined();
if (!pinListenerCall) {
return;
}
const [, pinListener, pinOptions] = pinListenerCall;
unmount();
expect(removeSpy).toHaveBeenCalledWith('keydown', pinListener, pinOptions);
});
});
// ---- Cursor sync ------------------------------------------------------------

View File

@@ -5593,10 +5593,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
"@signozhq/ui@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.9.tgz#e00f2ec86c5528eea91d1669510a702c4253de0d"
integrity sha512-L9DV0OF69Z2sMnxwPEGpSTiDxI/liT6+QfzngGVnxMl/9t3WKcPr1/p8dbPOcAS6bU9lQEBOiYlsoIejM8DsXw==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"

View File

@@ -10,34 +10,8 @@ import (
"github.com/gorilla/mux"
)
var (
serviceAccountAdminRoles = []string{
authtypes.SigNozAdminRoleName,
}
serviceAccountReadRoles = []string{
authtypes.SigNozAdminRoleName,
authtypes.SigNozEditorRoleName,
authtypes.SigNozViewerRoleName,
}
)
func serviceAccountCollectionSelectorCallback(_ *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResources, authtypes.WildCardSelectorString),
}, nil
}
func serviceAccountInstanceSelectorCallback(req *http.Request, _ authtypes.Claims) ([]authtypes.Selector, error) {
id := mux.Vars(req)["id"]
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, nil
}
func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
// POST /api/v1/service_accounts — Create (admin only)
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Create, authtypes.RelationCreate, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Create), handler.OpenAPIDef{
ID: "CreateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Create service account",
@@ -54,8 +28,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// GET /api/v1/service_accounts — List (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.Check(provider.serviceAccountHandler.List, authtypes.RelationList, serviceaccounttypes.TypeableMetaResourcesServiceAccounts, serviceAccountCollectionSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.List), handler.OpenAPIDef{
ID: "ListServiceAccounts",
Tags: []string{"serviceaccount"},
Summary: "List service accounts",
@@ -67,12 +40,11 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// GET /api/v1/service_accounts/me — GetMe (open access, self)
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.GetMe), handler.OpenAPIDef{
ID: "GetMyServiceAccount",
Tags: []string{"serviceaccount"},
@@ -90,8 +62,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// GET /api/v1/service_accounts/{id} — Get (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Get, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Get), handler.OpenAPIDef{
ID: "GetServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Gets a service account",
@@ -103,13 +74,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// GET /api/v1/service_accounts/{id}/roles — GetRoles (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.GetRoles, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.GetRoles), handler.OpenAPIDef{
ID: "GetServiceAccountRoles",
Tags: []string{"serviceaccount"},
Summary: "Gets service account roles",
@@ -121,13 +91,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// POST /api/v1/service_accounts/{id}/roles — SetRole (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.Check(provider.serviceAccountHandler.SetRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.SetRole), handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
@@ -144,8 +113,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// DELETE /api/v1/service_accounts/{id}/roles/{rid} — DeleteRole (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.DeleteRole, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.DeleteRole), handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
@@ -162,7 +130,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// PUT /api/v1/service_accounts/me — UpdateMe (open access, self)
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authZ.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
ID: "UpdateMyServiceAccount",
Tags: []string{"serviceaccount"},
@@ -180,8 +147,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// PUT /api/v1/service_accounts/{id} — Update (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Update, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Update), handler.OpenAPIDef{
ID: "UpdateServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account",
@@ -198,8 +164,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// DELETE /api/v1/service_accounts/{id} — Delete (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.Delete, authtypes.RelationDelete, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.Delete), handler.OpenAPIDef{
ID: "DeleteServiceAccount",
Tags: []string{"serviceaccount"},
Summary: "Deletes a service account",
@@ -216,8 +181,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// POST /api/v1/service_accounts/{id}/keys — CreateFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.CreateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.CreateFactorAPIKey), handler.OpenAPIDef{
ID: "CreateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Create a service account key",
@@ -234,8 +198,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// GET /api/v1/service_accounts/{id}/keys — ListFactorAPIKey (admin, editor, viewer)
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.Check(provider.serviceAccountHandler.ListFactorAPIKey, authtypes.RelationRead, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountReadRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.ListFactorAPIKey), handler.OpenAPIDef{
ID: "ListServiceAccountKeys",
Tags: []string{"serviceaccount"},
Summary: "List service account keys",
@@ -247,13 +210,12 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// PUT /api/v1/service_accounts/{id}/keys/{fid} — UpdateFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.UpdateFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.UpdateFactorAPIKey), handler.OpenAPIDef{
ID: "UpdateServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Updates a service account key",
@@ -270,8 +232,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
// DELETE /api/v1/service_accounts/{id}/keys/{fid} — RevokeFactorAPIKey (admin only)
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.Check(provider.serviceAccountHandler.RevokeFactorAPIKey, authtypes.RelationUpdate, serviceaccounttypes.TypeableMetaResourceServiceAccount, serviceAccountInstanceSelectorCallback, serviceAccountAdminRoles), handler.OpenAPIDef{
if err := router.Handle("/api/v1/service_accounts/{id}/keys/{fid}", handler.New(provider.authZ.AdminAccess(provider.serviceAccountHandler.RevokeFactorAPIKey), handler.OpenAPIDef{
ID: "RevokeServiceAccountKey",
Tags: []string{"serviceaccount"},
Summary: "Revoke a service account key",

View File

@@ -0,0 +1 @@
<svg id="b300f0d1-2ad8-4418-a1c5-23d0b9d21841" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="b8cad6fd-ec7f-45e9-be2a-125e8b87bd03" x1="10.79" y1="2.17" x2="10.79" y2="16.56" gradientUnits="userSpaceOnUse"><stop offset="0.18" stop-color="#5ea0ef"/><stop offset="1" stop-color="#0078d4"/></linearGradient></defs><title>Icon-web-43</title><rect x="3.7" y="5.49" width="1.18" height="5.26" rx="0.52" transform="translate(-3.83 12.41) rotate(-90)" fill="#b3b3b3"/><rect x="2.04" y="7.88" width="1.18" height="5.26" rx="0.52" transform="translate(-7.88 13.14) rotate(-90)" fill="#a3a3a3"/><rect x="3.7" y="10.26" width="1.18" height="5.26" rx="0.52" transform="translate(-8.6 17.19) rotate(-90)" fill="#7a7a7a"/><path d="M18,11a3.28,3.28,0,0,0-2.81-3.18,4.13,4.13,0,0,0-4.21-4,4.23,4.23,0,0,0-4,2.8,3.89,3.89,0,0,0-3.38,3.8,4,4,0,0,0,4.06,3.86l.36,0h6.58l.17,0A3.32,3.32,0,0,0,18,11Z" fill="url(#b8cad6fd-ec7f-45e9-be2a-125e8b87bd03)"/></svg>

After

Width:  |  Height:  |  Size: 971 B

View File

@@ -0,0 +1,167 @@
{
"id": "cdnprofile",
"title": "CDN Profile",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_activewebsocketconnections_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_averagewebsocketconnectionduration_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_backendhealthpercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_backendrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_backendrequestlatency_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_billableresponsesize_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_bytehitratio_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originhealthpercentage_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originlatency_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originshieldoriginrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originshieldratelimitrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originshieldrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_originshieldrequestsize_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_percentage4xx_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_percentage5xx_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_requestsize_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_responsesize_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_totallatency_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_webapplicationfirewallrequestcount_total",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Cdn",
"resourceType": "profiles",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "CDN Profile Overview",
"description": "Overview of CDN Profile metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor Azure CDN Profile with SigNoz
Collect key CDN Profile metrics and view them with an out of the box dashboard.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="a9c62307-1cd0-400c-911b-17ec6a9110ce" x1="9" y1="15.834" x2="9" y2="5.788" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bedd"/><stop offset="0.775" stop-color="#32d4f5"/></linearGradient></defs><title>MsPortalFx.base.images-7</title><g id="f31d214e-f09e-49e3-b3d2-7c5d55682d09"><g><path d="M.5,5.788h17a0,0,0,0,1,0,0v9.478a.568.568,0,0,1-.568.568H1.068A.568.568,0,0,1,.5,15.266V5.788A0,0,0,0,1,.5,5.788Z" fill="url(#a9c62307-1cd0-400c-911b-17ec6a9110ce)"/><path d="M1.071,2.166H16.929a.568.568,0,0,1,.568.568V5.788a0,0,0,0,1,0,0H.5a0,0,0,0,1,0,0V2.734A.568.568,0,0,1,1.071,2.166Z" fill="#0078d4"/><rect x="2.328" y="7.049" width="6.281" height="3.408" rx="0.283" fill="#0078d4"/><rect x="9.336" y="7.049" width="6.281" height="3.408" rx="0.283" fill="#fff"/><rect x="2.296" y="11.128" width="6.281" height="3.408" rx="0.283" fill="#0078d4"/><rect x="9.304" y="11.128" width="6.281" height="3.408" rx="0.283" fill="#0078d4"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,107 @@
{
"id": "storageaccountsblob",
"title": "Storage Accounts Blob Storage",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_availability_average",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "azure_blobcapacity_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_blobcount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_blobprovisionedsize_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_containercount_average",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "azure_egress_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_indexcapacity_average",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_ingress_total",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "azure_successe2elatency_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_successserverlatency_average",
"unit": "Milliseconds",
"type": "Gauge",
"description": ""
},
{
"name": "azure_transactions_total",
"unit": "Count",
"type": "Gauge",
"description": ""
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Storage",
"resourceType": "storageAccounts/blobServices",
"metrics": {},
"logs": {
"categoryGroups": ["allLogs"]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Blob Storage Overview",
"description": "Overview of Blob Storage",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor Storage Accounts Blob Storage with SigNoz
Collect key Blob Storage metrics and view them with an out of the box dashboard.

View File

@@ -373,7 +373,13 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
// update or create service
if svc.CloudIntegrationService == nil {
cloudIntegrationService := cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, req.Config)
var cloudIntegrationService *cloudintegrationtypes.CloudIntegrationService
cloudIntegrationService, err = cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, provider, req.Config)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.CreateService(ctx, orgID, cloudIntegrationService, provider)
} else {
err = svc.CloudIntegrationService.Update(provider, serviceID, req.Config)
@@ -434,3 +440,4 @@ func (handler *handler) AgentCheckIn(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, cloudintegrationtypes.NewGettableAgentCheckIn(provider, resp))
}

View File

@@ -376,21 +376,6 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
}
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
// Verify the caller holds the role they are trying to assign. This prevents
// privilege escalation — a caller cannot grant a role they don't have.
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
}
roleSelector := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, role.Name),
}
err = module.authz.CheckWithTupleCreation(ctx, claims, orgID, authtypes.RelationAssignee, authtypes.TypeableRole, roleSelector, roleSelector)
if err != nil {
return errors.Newf(errors.TypeForbidden, authtypes.ErrCodeAuthZForbidden, "caller does not hold the role %q being assigned", role.Name)
}
serviceAccount, err := module.GetWithRoles(ctx, orgID, id)
if err != nil {
return err
@@ -445,4 +430,3 @@ func apiKeyCacheKey(apiKey string) string {
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -1,135 +0,0 @@
package implserviceaccount
import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// typeableRegistry is a standalone RegisterTypeable implementation that can be
// used independently of the module lifecycle. This is needed because authz
// initialization requires RegisterTypeable entries before the service account
// module is created (the module depends on authz).
type typeableRegistry struct{}
func NewTypeableRegistry() authz.RegisterTypeable {
return &typeableRegistry{}
}
func (registry *typeableRegistry) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{
serviceaccounttypes.TypeableMetaResourceServiceAccount,
serviceaccounttypes.TypeableMetaResourcesServiceAccounts,
}
}
func (registry *typeableRegistry) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
authtypes.SigNozAdminRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationCreate,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationUpdate,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationDelete,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
authtypes.SigNozEditorRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
authtypes.SigNozViewerRoleName: {
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationList,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResources,
Name: serviceaccounttypes.TypeableMetaResourcesServiceAccounts.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResources, "*"),
),
},
{
ID: valuer.GenerateUUID(),
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: serviceaccounttypes.TypeableMetaResourceServiceAccount.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}

View File

@@ -100,7 +100,7 @@ func New(
sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]],
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, ...authz.RegisterTypeable) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
authzCallback func(context.Context, sqlstore.SQLStore, licensing.Licensing, dashboard.Module) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
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],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
@@ -328,11 +328,8 @@ func New(
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize service account typeable registry (decoupled from module lifecycle for DI)
serviceAccountRegistry := implserviceaccount.NewTypeableRegistry()
// Initialize authz
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard, serviceAccountRegistry)
authzProviderFactory, err := authzCallback(ctx, sqlstore, licensing, dashboard)
if err != nil {
return nil, err
}

View File

@@ -29,8 +29,13 @@ type AgentReport struct {
}
type AccountConfig struct {
// required till new providers are added
AWS *AWSAccountConfig `json:"aws" required:"true" nullable:"false"`
AWS *AWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
}
type UpdatableAccountConfig struct {
AWS *UpdatableAWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *UpdatableAzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
}
type PostableAccount struct {
@@ -41,7 +46,8 @@ type PostableAccount struct {
type PostableAccountConfig struct {
// as agent version is common for all providers, we can keep it at top level of this struct
AgentVersion string
AWS *AWSPostableAccountConfig `json:"aws" required:"true" nullable:"false"`
AWS *AWSPostableAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzurePostableAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
}
type Credentials struct {
@@ -58,7 +64,8 @@ type GettableAccountWithConnectionArtifact struct {
type ConnectionArtifact struct {
// required till new providers are added
AWS *AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
AWS *AWSConnectionArtifact `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureConnectionArtifact `json:"azure,omitempty" required:"false" nullable:"false"`
}
type GetConnectionArtifactRequest = PostableAccount
@@ -68,7 +75,7 @@ type GettableAccounts struct {
}
type UpdatableAccount struct {
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
Config *UpdatableAccountConfig `json:"config" required:"true" nullable:"false"`
}
func NewAccount(orgID valuer.UUID, provider CloudProviderType, config *AccountConfig) *Account {
@@ -119,6 +126,13 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
return nil, err
}
account.Config.AWS = awsConfig
case CloudProviderTypeAzure:
azureConfig := new(AzureAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), azureConfig)
if err != nil {
return nil, err
}
account.Config.Azure = azureConfig
}
if storableAccount.LastAgentReport != nil {
@@ -179,6 +193,24 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.AWS.Regions}}, nil
case CloudProviderTypeAzure:
if config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config can not be nil for Azure provider")
}
if config.Azure.DeploymentRegion == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment region is required for Azure provider")
}
if err := validateAzureRegion(config.Azure.DeploymentRegion); err != nil {
return nil, err
}
if len(config.Azure.ResourceGroups) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one resource group is required for Azure provider")
}
return &AccountConfig{Azure: &AzureAccountConfig{DeploymentRegion: config.Azure.DeploymentRegion, ResourceGroups: config.Azure.ResourceGroups}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -202,6 +234,16 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.Config.AWS.Regions}}, nil
case CloudProviderTypeAzure:
if config.Config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config can not be nil for Azure provider")
}
if len(config.Config.Azure.ResourceGroups) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one resource group is required for Azure provider")
}
return &AccountConfig{Azure: &AzureAccountConfig{ResourceGroups: config.Config.Azure.ResourceGroups}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -223,8 +265,14 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
}
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
// deployment region can not be updated once set for Azure
if provider == CloudProviderTypeAzure {
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
}
account.Config = config
account.UpdatedAt = time.Now()
return nil
}
@@ -288,6 +336,10 @@ func (config *AccountConfig) ToJSON() ([]byte, error) {
return json.Marshal(config.AWS)
}
if config.Azure != nil {
return json.Marshal(config.Azure)
}
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}

View File

@@ -48,7 +48,8 @@ type IntegrationConfig struct {
}
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws" required:"true" nullable:"false"`
AWS *AWSIntegrationConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureIntegrationConfig `json:"azure,omitempty" required:"false" nullable:"false"`
}
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.

View File

@@ -61,7 +61,8 @@ type StorableCloudIntegrationService struct {
// Following Service config types are only internally used to store service config in DB and use JSON snake case keys for backward compatibility.
type StorableServiceConfig struct {
AWS *StorableAWSServiceConfig
AWS *StorableAWSServiceConfig
Azure *StorableAzureServiceConfig
}
type StorableAWSServiceConfig struct {
@@ -78,6 +79,19 @@ type StorableAWSMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
type StorableAzureServiceConfig struct {
Logs *StorableAzureLogsServiceConfig `json:"logs,omitempty"`
Metrics *StorableAzureMetricsServiceConfig `json:"metrics,omitempty"`
}
type StorableAzureLogsServiceConfig struct {
Enabled bool `json:"enabled"`
}
type StorableAzureMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
// Scan scans value from DB.
func (r *StorableAgentReport) Scan(src any) error {
var data []byte
@@ -187,6 +201,30 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
}
return &StorableServiceConfig{AWS: storableAWSServiceConfig}, nil
case CloudProviderTypeAzure:
storableAzureServiceConfig := new(StorableAzureServiceConfig)
if supportedSignals.Logs {
if serviceConfig.Azure.Logs == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "logs config is required for Azure service: %s", serviceID.StringValue())
}
storableAzureServiceConfig.Logs = &StorableAzureLogsServiceConfig{
Enabled: serviceConfig.Azure.Logs.Enabled,
}
}
if supportedSignals.Metrics {
if serviceConfig.Azure.Metrics == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "metrics config is required for Azure service: %s", serviceID.StringValue())
}
storableAzureServiceConfig.Metrics = &StorableAzureMetricsServiceConfig{
Enabled: serviceConfig.Azure.Metrics.Enabled,
}
}
return &StorableServiceConfig{Azure: storableAzureServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -201,6 +239,13 @@ func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse AWS service config JSON")
}
return &StorableServiceConfig{AWS: awsConfig}, nil
case CloudProviderTypeAzure:
azureConfig := new(StorableAzureServiceConfig)
err := json.Unmarshal([]byte(jsonStr), azureConfig)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse Azure service config JSON")
}
return &StorableServiceConfig{Azure: azureConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -214,6 +259,13 @@ func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte,
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize AWS service config to JSON")
}
return jsonBytes, nil
case CloudProviderTypeAzure:
jsonBytes, err := json.Marshal(config.Azure)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize Azure service config to JSON")
}
return jsonBytes, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())

View File

@@ -23,6 +23,8 @@ type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type UpdatableAWSAccountConfig = AWSAccountConfig
// OldAWSCollectionStrategy is the backward-compatible snake_case form of AWSCollectionStrategy,
// used in the legacy integration_config response field for older agents.
type OldAWSCollectionStrategy struct {

View File

@@ -0,0 +1,148 @@
package cloudintegrationtypes
import (
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
AgentArmTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/azure-arm-template-%s.json")
AgentDeploymentStackName = valuer.NewString("signoz-integration")
)
type AzureAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ResourceGroups []string `json:"resourceGroups" required:"true" nullable:"false"`
}
type UpdatableAzureAccountConfig struct {
ResourceGroups []string `json:"resourceGroups" required:"true" nullable:"false"`
}
type AzurePostableAccountConfig = AzureAccountConfig
type AzureConnectionArtifact struct {
CLICommand string `json:"cliCommand" required:"true"`
CloudPowerShellCommand string `json:"cloudPowerShellCommand" required:"true"`
}
type AzureServiceConfig struct {
Logs *AzureServiceLogsConfig `json:"logs" required:"true"`
Metrics *AzureServiceMetricsConfig `json:"metrics" required:"true"`
}
type AzureServiceLogsConfig struct {
Enabled bool `json:"enabled"`
}
type AzureServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
type AzureTelemetryCollectionStrategy struct {
//https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types
ResourceProvider string `json:"resourceProvider" required:"true"`
ResourceType string `json:"resourceType" required:"true"`
Metrics *AzureMetricsCollectionStrategy `json:"metrics,omitempty" required:"false" nullable:"false"`
Logs *AzureLogsCollectionStrategy `json:"logs,omitempty" required:"false" nullable:"false"`
}
// AzureMetricsCollectionStrategy no additional config required for metrics, will be added in future as required.
type AzureMetricsCollectionStrategy struct{}
type AzureLogsCollectionStrategy struct {
// List of categories to enable for diagnostic settings, to start with it will have 'allLogs' and no filtering.
CategoryGroups []string `json:"categoryGroups" required:"true" nullable:"false"`
}
type AzureIntegrationConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ResourceGroups []string `json:"resourceGroups" required:"true" nullable:"false"`
TelemetryCollectionStrategy []*AzureTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
func NewAzureIntegrationConfig(
deploymentRegion string,
resourceGroups []string,
strategies []*AzureTelemetryCollectionStrategy,
) *AzureIntegrationConfig {
return &AzureIntegrationConfig{
DeploymentRegion: deploymentRegion,
ResourceGroups: resourceGroups,
TelemetryCollectionStrategy: strategies,
}
}
func NewAzureConnectionArtifact(cliCommand, cloudPowerShellCommand string) *AzureConnectionArtifact {
return &AzureConnectionArtifact{
CLICommand: cliCommand,
CloudPowerShellCommand: cloudPowerShellCommand,
}
}
func NewAzureConnectionCLICommand(
accountID valuer.UUID,
agentVersion string,
creds *Credentials,
cfg *AzurePostableAccountConfig,
) string {
templateURL := fmt.Sprintf(AgentArmTemplateS3Path.StringValue(), agentVersion)
lines := []string{
"az stack sub create",
fmt.Sprintf(" --name %s", AgentDeploymentStackName.StringValue()),
fmt.Sprintf(" --location %s", cfg.DeploymentRegion),
fmt.Sprintf(" --template-uri %s", templateURL),
" --parameters",
fmt.Sprintf(" location='%s'", cfg.DeploymentRegion),
fmt.Sprintf(" signozApiKey='%s'", creds.SigNozAPIKey),
fmt.Sprintf(" signozApiUrl='%s'", creds.SigNozAPIURL),
fmt.Sprintf(" signozIngestionUrl='%s'", creds.IngestionURL),
fmt.Sprintf(" signozIngestionKey='%s'", creds.IngestionKey),
fmt.Sprintf(" signozIntegrationAccountId='%s'", accountID.StringValue()),
fmt.Sprintf(" signozIntegrationAgentVersion='%s'", agentVersion),
" --action-on-unmanage deleteAll",
" --deny-settings-mode denyDelete",
}
return strings.Join(lines, " \\\n")
}
func NewAzureConnectionPowerShellCommand(
accountID valuer.UUID,
agentVersion string,
creds *Credentials,
cfg *AzurePostableAccountConfig,
) string {
params := []struct{ k, v string }{
{"location", cfg.DeploymentRegion},
{"signozApiKey", creds.SigNozAPIKey},
{"signozApiUrl", creds.SigNozAPIURL},
{"signozIngestionUrl", creds.IngestionURL},
{"signozIngestionKey", creds.IngestionKey},
{"signozIntegrationAccountId", accountID.StringValue()},
{"signozIntegrationAgentVersion", agentVersion},
{"rgName", "signoz-integration-rg"},
{"containerEnvName", "signoz-integration-agent-env"},
{"deploymentEnv", "production"},
}
const keyWidth = 36
var paramLines []string
for _, p := range params {
paramLines = append(paramLines, fmt.Sprintf(" %-*s= \"%s\"", keyWidth, p.k, p.v))
}
templateURL := fmt.Sprintf(AgentArmTemplateS3Path.StringValue(), agentVersion)
return strings.Join([]string{
"New-AzSubscriptionDeploymentStack `",
fmt.Sprintf(" -Name \"%s\" `", AgentDeploymentStackName.StringValue()),
fmt.Sprintf(" -Location \"%s\" `", cfg.DeploymentRegion),
fmt.Sprintf(" -TemplateUri \"%s\" `", templateURL),
" -TemplateParameterObject @{",
strings.Join(paramLines, "\n"),
" } `",
" -ActionOnUnmanage \"deleteAll\" `",
" -DenySettingsMode \"denyDelete\"",
}, "\n")
}

View File

@@ -165,3 +165,13 @@ func validateAWSRegion(region string) error {
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid AWS region: %s", region)
}
func validateAzureRegion(region string) error {
for _, r := range SupportedRegions[CloudProviderTypeAzure] {
if r.StringValue() == region {
return nil
}
}
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid Azure region: %s", region)
}

View File

@@ -21,8 +21,8 @@ type CloudIntegrationService struct {
}
type ServiceConfig struct {
// required till new providers are added
AWS *AWSServiceConfig `json:"aws" required:"true" nullable:"false"`
AWS *AWSServiceConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureServiceConfig `json:"azure,omitempty" required:"false" nullable:"false"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
@@ -88,7 +88,8 @@ type DataCollected struct {
// TelemetryCollectionStrategy 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 TelemetryCollectionStrategy struct {
AWS *AWSTelemetryCollectionStrategy `json:"aws" required:"true" nullable:"false"`
AWS *AWSTelemetryCollectionStrategy `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureTelemetryCollectionStrategy `json:"azure,omitempty" required:"false" nullable:"false"`
}
// Assets represents the collection of dashboards.
@@ -122,7 +123,18 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, config *ServiceConfig) *CloudIntegrationService {
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, provider CloudProviderType, config *ServiceConfig) (*CloudIntegrationService, error) {
switch provider {
case CloudProviderTypeAWS:
if config.AWS == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "AWS config is required for AWS service")
}
case CloudProviderTypeAzure:
if config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config is required for Azure service")
}
}
return &CloudIntegrationService{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -134,7 +146,7 @@ func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.U
Type: serviceID,
Config: config,
CloudIntegrationID: cloudIntegrationID,
}
}, nil
}
func NewCloudIntegrationServiceFromStorable(stored *StorableCloudIntegrationService, config *ServiceConfig) *CloudIntegrationService {
@@ -191,6 +203,22 @@ func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*S
}
return &ServiceConfig{AWS: awsServiceConfig}, nil
case CloudProviderTypeAzure:
azureServiceConfig := new(AzureServiceConfig)
if storableServiceConfig.Azure.Logs != nil {
azureServiceConfig.Logs = &AzureServiceLogsConfig{
Enabled: storableServiceConfig.Azure.Logs.Enabled,
}
}
if storableServiceConfig.Azure.Metrics != nil {
azureServiceConfig.Metrics = &AzureServiceMetricsConfig{
Enabled: storableServiceConfig.Azure.Metrics.Enabled,
}
}
return &ServiceConfig{Azure: azureServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -228,6 +256,10 @@ func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
logsEnabled := config.AWS.Logs != nil && config.AWS.Logs.Enabled
metricsEnabled := config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
return logsEnabled || metricsEnabled
case CloudProviderTypeAzure:
logsEnabled := config.Azure.Logs != nil && config.Azure.Logs.Enabled
metricsEnabled := config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
return logsEnabled || metricsEnabled
default:
return false
}
@@ -239,6 +271,8 @@ func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
return config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
case CloudProviderTypeAzure:
return config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
default:
return false
}
@@ -249,6 +283,8 @@ func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
return config.AWS.Logs != nil && config.AWS.Logs.Enabled
case CloudProviderTypeAzure:
return config.Azure.Logs != nil && config.Azure.Logs.Enabled
default:
return false
}
@@ -331,4 +367,3 @@ func GetDashboardsFromAssets(
return dashboards
}

View File

@@ -23,6 +23,10 @@ var (
AWSServiceS3Sync = ServiceID{valuer.NewString("s3sync")}
AWSServiceSNS = ServiceID{valuer.NewString("sns")}
AWSServiceSQS = ServiceID{valuer.NewString("sqs")}
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
)
func (ServiceID) Enum() []any {
@@ -40,6 +44,8 @@ func (ServiceID) Enum() []any {
AWSServiceS3Sync,
AWSServiceSNS,
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
}
}
@@ -60,6 +66,10 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AWSServiceSNS,
AWSServiceSQS,
},
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
},
}
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {

View File

@@ -26,11 +26,6 @@ var (
errInvalidServiceAccountName = errors.New(errors.TypeInvalidInput, ErrCodeServiceAccountInvalidInput, "name must start with a lowercase letter (a-z), contain only lowercase letters, numbers (0-9), and hyphens (-), and be at most 50 characters long")
)
var (
TypeableMetaResourceServiceAccount = authtypes.MustNewTypeableMetaResource(authtypes.MustNewName("service-account"))
TypeableMetaResourcesServiceAccounts = authtypes.MustNewTypeableMetaResources(authtypes.MustNewName("service-accounts"))
)
var (
ServiceAccountStatusActive = ServiceAccountStatus{valuer.NewString("active")}
ServiceAccountStatusDeleted = ServiceAccountStatus{valuer.NewString("deleted")}