mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 03:20:41 +00:00
Compare commits
6 Commits
test/dashb
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a570b1d31 | ||
|
|
555cb142db | ||
|
|
a6b6655e35 | ||
|
|
330b41f0ed | ||
|
|
e1b717fe36 | ||
|
|
d2565071e1 |
@@ -2239,8 +2239,6 @@ components:
|
||||
type: object
|
||||
CloudintegrationtypesGCPAccountConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
@@ -2250,79 +2248,13 @@ components:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionArtifact:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
type: object
|
||||
CloudintegrationtypesGCPConnectionMethod:
|
||||
enum:
|
||||
- manual
|
||||
- agent
|
||||
type: string
|
||||
CloudintegrationtypesGCPIntegrationConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
telemetryCollectionStrategy:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPTelemetryCollectionStrategy'
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
- telemetryCollectionStrategy
|
||||
type: object
|
||||
CloudintegrationtypesGCPLogsCollectionStrategy:
|
||||
nullable: true
|
||||
properties:
|
||||
sinkFilter:
|
||||
type: string
|
||||
required:
|
||||
- sinkFilter
|
||||
type: object
|
||||
CloudintegrationtypesGCPMetricsCollectionStrategy:
|
||||
nullable: true
|
||||
properties:
|
||||
metricNames:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- metricNames
|
||||
type: object
|
||||
CloudintegrationtypesGCPPostableAccountConfig:
|
||||
properties:
|
||||
connectionMethod:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionMethod'
|
||||
deploymentProjectId:
|
||||
type: string
|
||||
deploymentRegion:
|
||||
type: string
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- connectionMethod
|
||||
- deploymentProjectId
|
||||
- deploymentRegion
|
||||
- projectIds
|
||||
type: object
|
||||
CloudintegrationtypesGCPServiceConfig:
|
||||
properties:
|
||||
@@ -2345,16 +2277,6 @@ components:
|
||||
required:
|
||||
- enabled
|
||||
type: object
|
||||
CloudintegrationtypesGCPTelemetryCollectionStrategy:
|
||||
properties:
|
||||
logs:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPLogsCollectionStrategy'
|
||||
metrics:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPMetricsCollectionStrategy'
|
||||
required:
|
||||
- metrics
|
||||
- logs
|
||||
type: object
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifact:
|
||||
properties:
|
||||
connectionArtifact:
|
||||
@@ -2491,7 +2413,7 @@ components:
|
||||
azure:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
|
||||
gcp:
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPPostableAccountConfig'
|
||||
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
|
||||
type: object
|
||||
CloudintegrationtypesPostableAgentCheckIn:
|
||||
properties:
|
||||
@@ -2726,6 +2648,7 @@ components:
|
||||
projectIds:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- deploymentProjectId
|
||||
|
||||
@@ -88,10 +88,7 @@ tests/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ ├── routing.ts
|
||||
│ ├── dashboards.ts # dashboard seeding + query_range mocks; panels → panel-editor → panel-config extend it
|
||||
│ ├── panels.ts
|
||||
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/contract
|
||||
│ └── routing.ts
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -131,8 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- Drivers layer: `drivers/panels.ts` exports `withPanels(dashboardsTest)` and re-exports `drivers/dashboards.ts`, so a spec still imports one module.
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
@@ -7,66 +7,24 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
)
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
|
||||
return &gcpcloudprovider{
|
||||
serviceDefinitions: defStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(
|
||||
ctx context.Context,
|
||||
account *cloudintegrationtypes.Account,
|
||||
services []*cloudintegrationtypes.StorableCloudIntegrationService,
|
||||
) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
var metricNames []string
|
||||
var sinkFilters []string
|
||||
|
||||
for _, storedSvc := range services {
|
||||
svcCfg, err := cloudintegrationtypes.NewServiceConfigFromJSON(cloudintegrationtypes.CloudProviderTypeGCP, storedSvc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svcDef, err := g.GetServiceDefinition(ctx, storedSvc.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if svcDef.TelemetryCollectionStrategy.GCP == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
strategy := svcDef.TelemetryCollectionStrategy.GCP
|
||||
if svcCfg.IsMetricsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Metrics != nil {
|
||||
metricNames = append(metricNames, strategy.Metrics.MetricNames...)
|
||||
}
|
||||
|
||||
if svcCfg.IsLogsEnabled(cloudintegrationtypes.CloudProviderTypeGCP) && strategy.Logs != nil {
|
||||
sinkFilters = append(sinkFilters, strategy.Logs.SinkFilter)
|
||||
}
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
GCP: cloudintegrationtypes.NewGCPIntegrationConfig(account.Config.GCP, metricNames, sinkFilters),
|
||||
}, nil
|
||||
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
|
||||
// for manual flow we don't have any integration config to return, so returning empty config for now.
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
if account.Config.GCP.ConnectionMethod != cloudintegrationtypes.GCPConnectionMethodAgent {
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
connectionArtifact, err := cloudintegrationtypes.NewGCPConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, account.Config.GCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
GCP: connectionArtifact,
|
||||
}, nil
|
||||
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
|
||||
return &cloudintegrationtypes.ConnectionArtifact{}, nil
|
||||
}
|
||||
|
||||
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
@@ -76,7 +34,3 @@ func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID c
|
||||
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
|
||||
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
}
|
||||
|
||||
type gcpcloudprovider struct {
|
||||
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package implcloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -170,6 +171,18 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
errMessage := fmt.Sprintf("provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID)
|
||||
return nil, errors.New(errors.TypeAlreadyExists, cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected, errMessage)
|
||||
}
|
||||
|
||||
storableAccount, err := module.store.GetAccountByID(ctx, orgID, req.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -182,36 +195,41 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
|
||||
|
||||
syncState := account.NextSyncState(req.SyncedVersion)
|
||||
|
||||
// If account has been removed (disconnected), return a minimal response with empty integration config.
|
||||
if account.RemovedAt != nil {
|
||||
return module.checkInRemovedAccount(ctx, account, req.ProviderAccountID, syncState)
|
||||
}
|
||||
// Heartbeat stays frozen after removal, only the sync state is updated.
|
||||
if account.AgentReport != nil && syncState != nil {
|
||||
account.UpdateSyncState(syncState)
|
||||
|
||||
if err := account.ErrIfInvalidProviderAccountID(req.ProviderAccountID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
connectedAccount, err := module.store.GetConnectedAccountByProviderAccountID(ctx, orgID, req.ProviderAccountID, provider)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If a different integration is already connected to this provider account ID, reject the check-in.
|
||||
// Allow re-check-in from the same integration (e.g. agent restarting).
|
||||
if connectedAccount != nil && connectedAccount.ID != req.CloudIntegrationID {
|
||||
return nil, errors.Newf(
|
||||
errors.TypeAlreadyExists,
|
||||
cloudintegrationtypes.ErrCodeCloudIntegrationAlreadyConnected,
|
||||
"provider account id %s is already connected to cloud integration id %s", req.ProviderAccountID, connectedAccount.ID,
|
||||
)
|
||||
return cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
req.ProviderAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
), nil
|
||||
}
|
||||
|
||||
account.UpdateAgentReport(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
|
||||
|
||||
storableAccount, err = cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
err = module.store.UpdateAgentReport(ctx, storableAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -480,45 +498,11 @@ func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
|
||||
stats["cloudintegration.azure.connectedaccounts.count"] = azureAccountsCount
|
||||
}
|
||||
|
||||
gcpAccountsCount, err := module.store.CountConnectedAccounts(ctx, orgID, cloudintegrationtypes.CloudProviderTypeGCP)
|
||||
if err == nil {
|
||||
stats["cloudintegration.gcp.connectedaccounts.count"] = gcpAccountsCount
|
||||
}
|
||||
// NOTE: not adding stats for services for now.
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// checkInRemovedAccount returns an empty integration config and saves cleanup acknowledgments without refreshing the heartbeat.
|
||||
func (module *module) checkInRemovedAccount(ctx context.Context, account *cloudintegrationtypes.Account, providerAccountID string, syncState *cloudintegrationtypes.SyncState) (*cloudintegrationtypes.AgentCheckInResponse, error) {
|
||||
response := cloudintegrationtypes.NewAgentCheckInResponse(
|
||||
providerAccountID,
|
||||
account.ID.StringValue(),
|
||||
new(cloudintegrationtypes.ProviderIntegrationConfig),
|
||||
account.RemovedAt,
|
||||
syncState,
|
||||
)
|
||||
|
||||
if account.AgentReport == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
if syncState == nil {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
account.UpdateSyncState(syncState)
|
||||
storableAccount, err := cloudintegrationtypes.NewStorableCloudIntegration(account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := module.store.UpdateAgentReport(ctx, storableAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (module *module) getCloudProvider(provider cloudintegrationtypes.CloudProviderType) (cloudintegration.CloudProviderModule, error) {
|
||||
if cloudProviderModule, ok := module.cloudProvidersMap[provider]; ok {
|
||||
return cloudProviderModule, nil
|
||||
|
||||
@@ -3792,12 +3792,7 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
|
||||
resourceGroups: string[];
|
||||
}
|
||||
|
||||
export enum CloudintegrationtypesGCPConnectionMethodDTO {
|
||||
manual = 'manual',
|
||||
agent = 'agent',
|
||||
}
|
||||
export interface CloudintegrationtypesGCPAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4044,10 +4039,7 @@ export interface CloudintegrationtypesCollectedMetricDTO {
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesConnectionArtifactDTO {
|
||||
@@ -4086,62 +4078,8 @@ export interface CloudintegrationtypesDataCollectedDTO {
|
||||
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesGCPLogsCollectionStrategyDTO = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
sinkFilter: string;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type CloudintegrationtypesGCPMetricsCollectionStrategyDTO = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
metricNames: string[];
|
||||
} | null;
|
||||
|
||||
export interface CloudintegrationtypesGCPTelemetryCollectionStrategyDTO {
|
||||
logs: CloudintegrationtypesGCPLogsCollectionStrategyDTO | null;
|
||||
metrics: CloudintegrationtypesGCPMetricsCollectionStrategyDTO | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
telemetryCollectionStrategy: CloudintegrationtypesGCPTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGCPPostableAccountConfigDTO {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentProjectId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
projectIds: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
|
||||
@@ -4296,7 +4234,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
|
||||
export interface CloudintegrationtypesPostableAccountConfigDTO {
|
||||
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
|
||||
azure?: CloudintegrationtypesAzureAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPPostableAccountConfigDTO;
|
||||
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesPostableAccountDTO {
|
||||
@@ -4439,9 +4377,9 @@ export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
|
||||
*/
|
||||
deploymentRegion: string;
|
||||
/**
|
||||
* @type array
|
||||
* @type array,null
|
||||
*/
|
||||
projectIds: string[];
|
||||
projectIds: string[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.waitingMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
|
||||
import DeploymentRegionField from '../fields/DeploymentRegionField';
|
||||
import SetupDrawerFooter from '../SetupDrawerFooter';
|
||||
import SetupGuideCallout from '../SetupGuideCallout';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
import { usePrefillCredentials } from '../usePrefillCredentials';
|
||||
import AgentSetupCommand from './AgentSetupCommand';
|
||||
|
||||
import styles from './AgentSetup.module.scss';
|
||||
|
||||
interface AgentSetupProps {
|
||||
footerSlot: HTMLDivElement | null;
|
||||
onConnectionMethodChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
onCommandGenerated: () => void;
|
||||
/** Asks to leave the setup; the parent may confirm before closing. */
|
||||
onCancel: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AgentSetup({
|
||||
footerSlot,
|
||||
onConnectionMethodChange,
|
||||
onCommandGenerated,
|
||||
onCancel,
|
||||
onClose,
|
||||
}: AgentSetupProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
generateCommand,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
connectionCommand,
|
||||
commandProjectId,
|
||||
modalState,
|
||||
checkConnectionAgain,
|
||||
} = useAgentAccountSetup({ onClose, onCommandGenerated });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_SETUP_FORM_VALUES,
|
||||
});
|
||||
|
||||
usePrefillCredentials(connectionParams, setValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionMethodSelector
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
onChange={onConnectionMethodChange}
|
||||
disabled={isLoading || Boolean(connectionCommand)}
|
||||
/>
|
||||
<SetupGuideCallout {...AGENT_SETUP_GUIDE} />
|
||||
{connectionCommand ? (
|
||||
<>
|
||||
<AgentSetupCommand
|
||||
deploymentProjectId={commandProjectId}
|
||||
command={connectionCommand}
|
||||
/>
|
||||
{modalState === ModalStateEnum.WAITING && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon={false}
|
||||
title={
|
||||
<div className={styles.waitingMessage}>
|
||||
<LoaderCircle size={14} className="animate-spin" />
|
||||
Waiting for GCP account connection, retrying in 10 secs...
|
||||
</div>
|
||||
}
|
||||
testId="gcp-agent-waiting"
|
||||
/>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
title="We couldn't establish a connection to your GCP account. Please try again"
|
||||
testId="gcp-agent-connection-error"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DeploymentProjectIdField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
/>
|
||||
<DeploymentRegionField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.agent}
|
||||
/>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SetupDrawerFooter
|
||||
slot={footerSlot}
|
||||
errorTitle="Failed to generate the GCP setup command"
|
||||
error={submitError}
|
||||
onDismissError={clearSubmitError}
|
||||
onCancel={onCancel}
|
||||
>
|
||||
{!connectionCommand && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(generateCommand)}
|
||||
loading={isLoading || isConnectionParamsLoading}
|
||||
testId="gcp-generate-command-btn"
|
||||
>
|
||||
Generate Command
|
||||
</Button>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={checkConnectionAgain}
|
||||
testId="gcp-check-connection-btn"
|
||||
>
|
||||
Check Again
|
||||
</Button>
|
||||
)}
|
||||
</SetupDrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSetup;
|
||||
@@ -1,31 +0,0 @@
|
||||
.commands {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.commandsHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
|
||||
// CodeBlock renders the copy button's wrapper first and the highlighted code last;
|
||||
// the code theme sets the block's margin and padding inline, hence !important.
|
||||
.command {
|
||||
> div:first-child {
|
||||
top: var(--spacing-6);
|
||||
right: var(--spacing-6);
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
// The theme's top margin pushes the bordered block under the copy button.
|
||||
margin: 0 !important;
|
||||
// Keeps long lines from running underneath the copy button.
|
||||
padding-right: var(--spacing-24) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CodeBlock from 'components/CodeBlock/CodeBlock';
|
||||
|
||||
import styles from './AgentSetupCommand.module.scss';
|
||||
|
||||
interface AgentSetupCommandProps {
|
||||
deploymentProjectId: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
function AgentSetupCommand({
|
||||
deploymentProjectId,
|
||||
command,
|
||||
}: AgentSetupCommandProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commands} data-testid="gcp-agent-command">
|
||||
<div className={styles.commandsHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Deploy the SigNoz agent
|
||||
</Typography.Text>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
Run this command in Google Cloud Shell, or in a terminal where gcloud is
|
||||
signed in to <span className={styles.mono}>{deploymentProjectId}</span>. It
|
||||
contains your SigNoz keys, so do not share it.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CodeBlock language="bash" code={command} className={styles.command} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSetupCommand;
|
||||
@@ -1,183 +0,0 @@
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
fillAgentForm,
|
||||
onClose,
|
||||
onCommandGenerated,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
AgentSetupApiMock,
|
||||
} from './agentSetupTestHelpers';
|
||||
import {
|
||||
connectionCredentials,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpConnectionCommand,
|
||||
} from '../../../__tests__/mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup form', () => {
|
||||
let api: AgentSetupApiMock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
api = mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('asks only for the deployment project, region and credentials', async () => {
|
||||
renderAgentSetup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-signoz-api-url-input')).toHaveTextContent(
|
||||
connectionCredentials.sigNozApiUrl,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('gcp-deployment-region-select'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-account-name-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.querySelector('#gcp-project-ids-select')).toBeNull();
|
||||
expect(screen.getByTestId('gcp-setup-guide-callout')).toHaveTextContent(
|
||||
'SigNoz integration sets up everything needed',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks submission and surfaces validation errors when the form is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Please enter the deployment project ID'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Please select a region')).toBeInTheDocument();
|
||||
expect(api.createAccountPayload).toBeNull();
|
||||
});
|
||||
|
||||
it('creates an agent account and shows its setup command', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(api.createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'agent',
|
||||
deploymentRegion: 'asia-south1',
|
||||
deploymentProjectId: DEPLOYMENT_PROJECT_ID,
|
||||
projectIds: [DEPLOYMENT_PROJECT_ID],
|
||||
},
|
||||
},
|
||||
credentials: connectionCredentials,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
|
||||
gcpConnectionCommand.cliCommand,
|
||||
);
|
||||
expect(screen.getByTestId('gcp-agent-command')).toHaveTextContent(
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('gcp-generate-command-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-deployment-project-id-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('radiogroup')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
expect(onCommandGenerated).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(api.checkInCalled).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the backend error inline and keeps the form when creation fails', async () => {
|
||||
server.use(
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: {
|
||||
message:
|
||||
'projectIds must contain only deploymentProjectId for GCP agent setup',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-connect-error')).toHaveTextContent(
|
||||
'projectIds must contain only deploymentProjectId for GCP agent setup',
|
||||
);
|
||||
});
|
||||
expect(screen.queryByTestId('gcp-agent-command')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks the connection method and keeps the submitted project while the command is generating', async () => {
|
||||
server.use(
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.delay(300), ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radiogroup')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'-edited',
|
||||
);
|
||||
|
||||
const commands = await screen.findByTestId('gcp-agent-command');
|
||||
expect(commands).toHaveTextContent(`signed in to ${DEPLOYMENT_PROJECT_ID}.`);
|
||||
expect(commands).not.toHaveTextContent(`${DEPLOYMENT_PROJECT_ID}-edited`);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
fillAgentForm,
|
||||
onClose,
|
||||
renderAgentSetup,
|
||||
mockAgentSetupApi,
|
||||
} from './agentSetupTestHelpers';
|
||||
import {
|
||||
connectedAgentAccountResponse,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from '../../../__tests__/mockData';
|
||||
|
||||
// `useAgentAccountSetup` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('GCP AgentSetup connection polling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAgentSetupApi();
|
||||
});
|
||||
|
||||
it('waits for the agent after showing the command', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-waiting');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-check-connection-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the drawer once the agent checks in', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a timeout and lets the user check again', async () => {
|
||||
const realNow = Date.now.bind(Date);
|
||||
let clockOffset = 0;
|
||||
const nowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => realNow() + clockOffset);
|
||||
|
||||
let pollCount = 0;
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) => {
|
||||
pollCount += 1;
|
||||
if (pollCount === 1) {
|
||||
// Polling started before this response, so jumping the clock
|
||||
// past the 10 minute window makes this first poll time out.
|
||||
clockOffset += 11 * 60 * 1000;
|
||||
return res(ctx.status(200), ctx.json(pendingAgentAccountResponse));
|
||||
}
|
||||
return res(ctx.status(200), ctx.json(connectedAgentAccountResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-connection-error');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-check-connection-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a retryable error when the connection status cannot be fetched', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderAgentSetup();
|
||||
|
||||
await fillAgentForm(user);
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-connection-error');
|
||||
expect(screen.getByTestId('gcp-check-connection-btn')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
|
||||
import AgentSetup from '../AgentSetup';
|
||||
import SetupDrawerLayout from '../../SetupDrawerLayout';
|
||||
import {
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CHECK_IN_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from '../../../__tests__/mockData';
|
||||
|
||||
export const DEPLOYMENT_PROJECT_ID = 'my-deployment-project-123';
|
||||
|
||||
type AgentSetupProps = ComponentProps<typeof AgentSetup>;
|
||||
|
||||
export const onClose = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onClose']
|
||||
>;
|
||||
export const onConnectionMethodChange = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onConnectionMethodChange']
|
||||
>;
|
||||
export const onCommandGenerated = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onCommandGenerated']
|
||||
>;
|
||||
export const onCancel = jest.fn() as jest.MockedFunction<
|
||||
AgentSetupProps['onCancel']
|
||||
>;
|
||||
|
||||
export const renderAgentSetup = (): void => {
|
||||
render(
|
||||
<SetupDrawerLayout onClose={onClose}>
|
||||
{(footerSlot): JSX.Element => (
|
||||
<AgentSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={onConnectionMethodChange}
|
||||
onCommandGenerated={onCommandGenerated}
|
||||
onCancel={onCancel}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</SetupDrawerLayout>,
|
||||
);
|
||||
};
|
||||
|
||||
export const fillAgentForm = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
DEPLOYMENT_PROJECT_ID,
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
};
|
||||
|
||||
export interface AgentSetupApiMock {
|
||||
readonly createAccountPayload: Record<string, unknown> | null;
|
||||
readonly checkInCalled: boolean;
|
||||
}
|
||||
|
||||
/** Registers the agent setup endpoints; the polled account stays pending. */
|
||||
export const mockAgentSetupApi = (): AgentSetupApiMock => {
|
||||
let createAccountPayload: Record<string, unknown> | null = null;
|
||||
let checkInCalled = false;
|
||||
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
// check_in is registered first, it is a more specific path than
|
||||
// /accounts and msw matches handlers in registration order.
|
||||
rest.post(GCP_CHECK_IN_URL, (_req, res, ctx) => {
|
||||
checkInCalled = true;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
rest.post(GCP_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
|
||||
createAccountPayload = await req.json();
|
||||
return res(ctx.status(201), ctx.json(createAgentAccountResponse));
|
||||
}),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
get createAccountPayload(): Record<string, unknown> | null {
|
||||
return createAccountPayload;
|
||||
},
|
||||
get checkInCalled(): boolean {
|
||||
return checkInCalled;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
.setupDrawer {
|
||||
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
|
||||
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
|
||||
|
||||
// Input and ComboboxSimple default to --border borders, inherited text and
|
||||
// --muted-foreground placeholders; the drawer wants the dimmer --l2-border with
|
||||
// brighter values and duller placeholders. Backgrounds are left alone — both
|
||||
// components default to transparent and every field sits on an --l2-background
|
||||
// surface already. Focus borders stay at their per-component defaults.
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--l2-border);
|
||||
--input-foreground: var(--l1-foreground);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
--combobox-trigger-border-color: var(--l2-border);
|
||||
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-10) var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot='select-content'] {
|
||||
width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.projectIdsSelect {
|
||||
:global(.ant-select-selector) {
|
||||
min-height: 36px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&:hover :global(.ant-select-selector),
|
||||
&:global(.ant-select-focused) :global(.ant-select-selector) {
|
||||
border-color: var(--l2-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// antd defaults to 14px; pin to 13px to line up with the Input/Combobox fields.
|
||||
:global(.ant-select-selection-placeholder),
|
||||
:global(.ant-select-selection-search-input),
|
||||
:global(.ant-select-selection-item) {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-placeholder) {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-search-input) {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item-remove) {
|
||||
color: var(--l3-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,288 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
|
||||
import { useCloudAccountSetupDrawer } from 'hooks/integration/gcp/useCloudAccountSetupDrawer';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import AgentSetup from './AgentSetup/AgentSetup';
|
||||
import ManualSetup from './ManualSetup/ManualSetup';
|
||||
import SetupDrawerLayout from './SetupDrawerLayout';
|
||||
import ConnectionSecretsFields from './ConnectionSecretsFields';
|
||||
import FieldLabel from './FieldLabel';
|
||||
import FlowSelector from './FlowSelector';
|
||||
import SetupGuideCallout from './SetupGuideCallout';
|
||||
import { GcpSetupFormValues, SetupFlow } from './types';
|
||||
|
||||
import styles from './CloudAccountSetupDrawer.module.scss';
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
const DEFAULT_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
|
||||
function CloudAccountSetupDrawer({
|
||||
onClose,
|
||||
}: IntegrationModalProps): JSX.Element {
|
||||
const [connectionMethod, setConnectionMethod] =
|
||||
useState<CloudintegrationtypesGCPConnectionMethodDTO>(
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
);
|
||||
// Set once the agent command exists; closing after that loses the pending setup.
|
||||
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useCloudAccountSetupDrawer({ onClose });
|
||||
|
||||
const requestClose = (): void => {
|
||||
if (hasGeneratedCommand) {
|
||||
setIsCloseConfirmOpen(true);
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
});
|
||||
|
||||
const [flow, setFlow] = useState<SetupFlow>('manual');
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footerContainer}>
|
||||
{submitError && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={clearSubmitError}
|
||||
title="Failed to connect GCP account"
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{submitError}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading || isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetupDrawerLayout onClose={requestClose}>
|
||||
{(footerSlot): JSX.Element =>
|
||||
connectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent ? (
|
||||
<AgentSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={setConnectionMethod}
|
||||
onCommandGenerated={(): void => setHasGeneratedCommand(true)}
|
||||
onCancel={requestClose}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : (
|
||||
<ManualSetup
|
||||
footerSlot={footerSlot}
|
||||
onConnectionMethodChange={setConnectionMethod}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
</SetupDrawerLayout>
|
||||
<DialogWrapper
|
||||
open={isCloseConfirmOpen}
|
||||
onOpenChange={setIsCloseConfirmOpen}
|
||||
title="Close GCP agent setup?"
|
||||
testId="gcp-close-setup-confirm"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="highlight-danger"
|
||||
onClick={onClose}
|
||||
testId="gcp-close-setup-btn"
|
||||
>
|
||||
Close Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsCloseConfirmOpen(false)}
|
||||
testId="gcp-continue-setup-btn"
|
||||
>
|
||||
Continue Setup
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography>
|
||||
The agent has not connected yet. If you close now, you will have to start
|
||||
the installation process again with a new setup command.
|
||||
</Typography>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="base"
|
||||
footer={footer}
|
||||
drawerHeaderProps={{ className: styles.title }}
|
||||
>
|
||||
<FlowSelector value={flow} onChange={setFlow} />
|
||||
<SetupGuideCallout />
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip="The GCP project that hosts your OTel Collector deployment — often separate from the projects you actually monitor"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip="The GCP region where your OTel Collector will be deployed"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Select
|
||||
id="gcp-project-ids-select"
|
||||
className={cx(styles.fullWidth, styles.projectIdsSelect)}
|
||||
mode="tags"
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value)}
|
||||
placeholder="Add project IDs…"
|
||||
tokenSeparators={[',', ' ']}
|
||||
notFoundContent={null}
|
||||
suffixIcon={null}
|
||||
getPopupContainer={popupContainer}
|
||||
data-testid="gcp-project-ids-select"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ConnectionMethodSelector.module.scss';
|
||||
|
||||
interface ConnectionMethodSelectorProps {
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
onChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ConnectionMethodSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ConnectionMethodSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<Typography.Text
|
||||
weight="bold"
|
||||
size="base"
|
||||
className={styles.drawerSurfaceHead}
|
||||
>
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
textOverflow="wrap"
|
||||
value={value}
|
||||
onChange={(next): void =>
|
||||
onChange(next as CloudintegrationtypesGCPConnectionMethodDTO)
|
||||
}
|
||||
disabled={disabled}
|
||||
disabledTooltip="The connection method can't be changed once setup has started"
|
||||
items={[
|
||||
{
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
testId: 'gcp-connection-method-manual',
|
||||
label: (
|
||||
<>
|
||||
<Typography.Text weight="semibold" size="base" display="block">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.methodRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
testId: 'gcp-connection-method-agent',
|
||||
label: (
|
||||
<>
|
||||
<Typography.Text weight="semibold" size="base" display="block">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.methodRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionMethodSelector.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ConnectionMethodSelector;
|
||||
@@ -21,6 +21,10 @@
|
||||
composes: mono from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from './FieldLabel';
|
||||
import FormField from './FormField';
|
||||
import { GcpSetupFormValues } from './types';
|
||||
import { SecretFieldType, validateSecretValue } from './validators';
|
||||
import styles from './ConnectionSecretsFields.module.scss';
|
||||
@@ -142,32 +141,43 @@ function ConnectionSecretsFields({
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={field.name}
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
validateSecretValue(field.label, field.type, value),
|
||||
}}
|
||||
render={({ field: rhfField, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor={field.testId}
|
||||
label={field.label}
|
||||
tooltip={field.tooltip}
|
||||
error={fieldState.error}
|
||||
>
|
||||
<Input
|
||||
id={field.testId}
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder={field.placeholder}
|
||||
value={rhfField.value}
|
||||
onChange={(e): void => rhfField.onChange(e.target.value)}
|
||||
testId={field.testId}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
<div key={field.name} className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor={field.testId}
|
||||
label={field.label}
|
||||
tooltip={field.tooltip}
|
||||
/>
|
||||
<Controller
|
||||
name={field.name}
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
validateSecretValue(field.label, field.type, value),
|
||||
}}
|
||||
render={({ field: rhfField, fieldState }): JSX.Element => (
|
||||
<>
|
||||
<Input
|
||||
id={field.testId}
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder={field.placeholder}
|
||||
value={rhfField.value}
|
||||
onChange={(e): void => rhfField.onChange(e.target.value)}
|
||||
testId={field.testId}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{fieldState.error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
composes: drawerSurfaceHead from './shared.module.scss';
|
||||
}
|
||||
|
||||
.methodRadioDesc {
|
||||
.flowRadioTitle {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flowRadioDesc {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { SetupFlow } from './types';
|
||||
import styles from './FlowSelector.module.scss';
|
||||
|
||||
interface FlowSelectorProps {
|
||||
value: SetupFlow;
|
||||
onChange: (flow: SetupFlow) => void;
|
||||
}
|
||||
|
||||
function FlowSelector({ value, onChange }: FlowSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
|
||||
<div className={styles.drawerSurfaceHead}>
|
||||
<Typography.Text weight="bold" size="base">
|
||||
Connection method
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
textOverflow="wrap"
|
||||
value={value}
|
||||
onChange={(next): void => onChange(next as SetupFlow)}
|
||||
items={[
|
||||
{
|
||||
value: 'manual',
|
||||
testId: 'gcp-flow-manual',
|
||||
label: (
|
||||
<>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect Manually
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
Deploy your own OTel Collector.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'agent',
|
||||
testId: 'gcp-flow-agent',
|
||||
disabled: true,
|
||||
disabledTooltip: 'Soon',
|
||||
label: (
|
||||
<>
|
||||
<div className={styles.flowRadioTitle}>
|
||||
<Typography.Text weight="semibold" size="base">
|
||||
Connect via Agent
|
||||
</Typography.Text>
|
||||
<Badge color="primary" variant="solid">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<Typography.Text
|
||||
as="p"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.flowRadioDesc}
|
||||
>
|
||||
SigNoz deploys and manages the collector for you.
|
||||
</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowSelector;
|
||||
@@ -1,7 +0,0 @@
|
||||
.drawerSection {
|
||||
composes: drawerSection from './shared.module.scss';
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
composes: fieldError from './shared.module.scss';
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { FieldError } from 'react-hook-form';
|
||||
|
||||
import FieldLabel from './FieldLabel';
|
||||
|
||||
import styles from './FormField.module.scss';
|
||||
|
||||
interface FormFieldProps {
|
||||
htmlFor: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
required?: boolean;
|
||||
error?: FieldError;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** A labelled setup drawer field with its validation message below the input. */
|
||||
function FormField({
|
||||
htmlFor,
|
||||
label,
|
||||
tooltip,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
}: FormFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.drawerSection}>
|
||||
<FieldLabel
|
||||
htmlFor={htmlFor}
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
required={required}
|
||||
/>
|
||||
{children}
|
||||
{error && (
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
role="alert"
|
||||
className={styles.fieldError}
|
||||
>
|
||||
{error.message}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FormField.defaultProps = {
|
||||
required: false,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
export default FormField;
|
||||
@@ -1,3 +0,0 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useManualAccountSetup } from 'hooks/integration/gcp/useManualAccountSetup';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
||||
import ProjectIdsInput from '../../ProjectIdsInput';
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import FormField from '../FormField';
|
||||
import DeploymentProjectIdField from '../fields/DeploymentProjectIdField';
|
||||
import DeploymentRegionField from '../fields/DeploymentRegionField';
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import SetupDrawerFooter from '../SetupDrawerFooter';
|
||||
import SetupGuideCallout from '../SetupGuideCallout';
|
||||
import { MANUAL_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
import { usePrefillCredentials } from '../usePrefillCredentials';
|
||||
|
||||
import styles from './ManualSetup.module.scss';
|
||||
|
||||
interface ManualSetupProps {
|
||||
footerSlot: HTMLDivElement | null;
|
||||
onConnectionMethodChange: (
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ManualSetup({
|
||||
footerSlot,
|
||||
onConnectionMethodChange,
|
||||
onClose,
|
||||
}: ManualSetupProps): JSX.Element {
|
||||
const {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
} = useManualAccountSetup({ onClose });
|
||||
|
||||
const { control, handleSubmit, setValue } = useForm<GcpSetupFormValues>({
|
||||
defaultValues: DEFAULT_SETUP_FORM_VALUES,
|
||||
});
|
||||
|
||||
usePrefillCredentials(connectionParams, setValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionMethodSelector
|
||||
value={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
onChange={onConnectionMethodChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<SetupGuideCallout {...MANUAL_SETUP_GUIDE} />
|
||||
<Controller
|
||||
name="accountName"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter an account name' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-account-name-input"
|
||||
label="Account Name"
|
||||
tooltip="A label to identify this group of GCP projects (org ID, billing email, or any descriptive name)"
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<Input
|
||||
id="gcp-account-name-input"
|
||||
className={styles.fullWidth}
|
||||
placeholder="e.g. my-org or billing@company.com"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-account-name-input"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
<DeploymentProjectIdField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
/>
|
||||
<DeploymentRegionField
|
||||
control={control}
|
||||
connectionMethod={CloudintegrationtypesGCPConnectionMethodDTO.manual}
|
||||
/>
|
||||
<Controller
|
||||
name="projectIds"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): true | string =>
|
||||
value.length > 0 || 'Please add at least one project ID',
|
||||
}}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-project-ids-select"
|
||||
label="Projects to Monitor"
|
||||
tooltip="Enter each GCP project ID then press Enter"
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<ProjectIdsInput
|
||||
id="gcp-project-ids-select"
|
||||
testId="gcp-project-ids-select"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
<ConnectionSecretsFields
|
||||
control={control}
|
||||
isLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
<SetupDrawerFooter
|
||||
slot={footerSlot}
|
||||
errorTitle="Failed to connect GCP account"
|
||||
error={submitError}
|
||||
onDismissError={clearSubmitError}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit(connectAccount)}
|
||||
loading={isLoading || isConnectionParamsLoading}
|
||||
testId="gcp-connect-account-btn"
|
||||
>
|
||||
Connect Account
|
||||
</Button>
|
||||
</SetupDrawerFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManualSetup;
|
||||
@@ -1,14 +0,0 @@
|
||||
.footerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
|
||||
import styles from './SetupDrawerFooter.module.scss';
|
||||
|
||||
interface SetupDrawerFooterProps {
|
||||
slot: HTMLDivElement | null;
|
||||
errorTitle: string;
|
||||
error: string | null;
|
||||
onDismissError: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function SetupDrawerFooter({
|
||||
slot,
|
||||
errorTitle,
|
||||
error,
|
||||
onDismissError,
|
||||
onCancel,
|
||||
children,
|
||||
}: SetupDrawerFooterProps): JSX.Element | null {
|
||||
if (!slot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.footerContainer}>
|
||||
{error && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
action="dismissible"
|
||||
onClick={onDismissError}
|
||||
title={errorTitle}
|
||||
testId="gcp-connect-error"
|
||||
>
|
||||
{error}
|
||||
</Callout>
|
||||
)}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
testId="gcp-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
slot,
|
||||
);
|
||||
}
|
||||
|
||||
SetupDrawerFooter.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default SetupDrawerFooter;
|
||||
@@ -1,36 +0,0 @@
|
||||
.setupDrawer {
|
||||
// Bounded flex column so the header/footer stay put and only the body
|
||||
// scrolls when content overflows.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot='drawer-header'],
|
||||
[data-slot='drawer-footer'] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// The drawer body renders inside [data-slot='drawer-description'] — this is
|
||||
// the only region allowed to scroll.
|
||||
[data-slot='drawer-description'] {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// The region menu renders in place (no portal), so it needs its own surface
|
||||
// to sit above the fields below it.
|
||||
[data-slot='combobox-content'] {
|
||||
z-index: 5;
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
}
|
||||
|
||||
.footerSlot {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
|
||||
import styles from './SetupDrawerLayout.module.scss';
|
||||
|
||||
interface SetupDrawerLayoutProps {
|
||||
onClose: () => void;
|
||||
/** Receives the footer slot element that SetupDrawerFooter portals into. */
|
||||
children: (footerSlot: HTMLDivElement | null) => ReactNode;
|
||||
}
|
||||
|
||||
/** Stays mounted across connection method switches so the drawer does not replay its open animation. */
|
||||
function SetupDrawerLayout({
|
||||
onClose,
|
||||
children,
|
||||
}: SetupDrawerLayoutProps): JSX.Element {
|
||||
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className={styles.setupDrawer}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Connect Google Cloud Platform"
|
||||
width="extra-wide"
|
||||
footer={<div ref={setFooterSlot} className={styles.footerSlot} />}
|
||||
testId="gcp-setup-drawer"
|
||||
>
|
||||
{children(footerSlot)}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetupDrawerLayout;
|
||||
@@ -4,30 +4,24 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './SetupGuideCallout.module.scss';
|
||||
|
||||
interface SetupGuideCalloutProps {
|
||||
description: string;
|
||||
docsUrl: string;
|
||||
linkLabel: string;
|
||||
}
|
||||
const GCP_INTEGRATION_DOCS_URL =
|
||||
'https://signoz.io/docs/integrations/gcp/gcp-integration/';
|
||||
|
||||
function SetupGuideCallout({
|
||||
description,
|
||||
docsUrl,
|
||||
linkLabel,
|
||||
}: SetupGuideCalloutProps): JSX.Element {
|
||||
function SetupGuideCallout(): JSX.Element {
|
||||
return (
|
||||
<Callout icon={<KeyRound />} testId="gcp-setup-guide-callout">
|
||||
<Typography.Text as="span" size="base">
|
||||
{description}
|
||||
Please go through our GCP integration guide, which covers all prerequisites
|
||||
— service account, IAM roles, and resource setup.
|
||||
</Typography.Text>
|
||||
<a
|
||||
className={styles.guideLink}
|
||||
href={docsUrl}
|
||||
href={GCP_INTEGRATION_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="gcp-setup-guide-link"
|
||||
>
|
||||
{linkLabel}
|
||||
GCP integration guide
|
||||
<ArrowUpRight size={12} />
|
||||
</a>
|
||||
</Callout>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { GcpSetupFormValues } from './types';
|
||||
|
||||
export const DEFAULT_SETUP_FORM_VALUES: GcpSetupFormValues = {
|
||||
accountName: '',
|
||||
deploymentProjectId: '',
|
||||
deploymentRegion: '',
|
||||
projectIds: [],
|
||||
sigNozApiUrl: '',
|
||||
sigNozApiKey: '',
|
||||
ingestionUrl: '',
|
||||
ingestionKey: '',
|
||||
};
|
||||
|
||||
export const MANUAL_SETUP_GUIDE = {
|
||||
description:
|
||||
'Please follow our setup guide to create the resources needed to complete the integration.',
|
||||
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
|
||||
linkLabel: 'View setup guide',
|
||||
};
|
||||
|
||||
export const AGENT_SETUP_GUIDE = {
|
||||
description:
|
||||
'SigNoz integration sets up everything needed to collect telemetry from your project.',
|
||||
// TODO: point to the agent setup docs once they are published.
|
||||
docsUrl: 'https://signoz.io/docs/integrations/gcp/gcp-integration/',
|
||||
linkLabel: 'Learn more',
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FormField from '../FormField';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
|
||||
import styles from './Fields.module.scss';
|
||||
|
||||
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
|
||||
'The GCP project that hosts your OTel Collector deployment, often separate from the projects you actually monitor',
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
|
||||
'The GCP project where SigNoz deploys the agent. The agent monitors this project.',
|
||||
};
|
||||
|
||||
interface DeploymentProjectIdFieldProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}
|
||||
|
||||
function DeploymentProjectIdField({
|
||||
control,
|
||||
connectionMethod,
|
||||
}: DeploymentProjectIdFieldProps): JSX.Element {
|
||||
return (
|
||||
<Controller
|
||||
name="deploymentProjectId"
|
||||
control={control}
|
||||
rules={{ required: 'Please enter the deployment project ID' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-deployment-project-id-input"
|
||||
label="Deployment Project ID"
|
||||
tooltip={TOOLTIPS[connectionMethod]}
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<Input
|
||||
id="gcp-deployment-project-id-input"
|
||||
className={cx(styles.fullWidth, styles.mono)}
|
||||
placeholder="e.g. my-deployment-project-123"
|
||||
value={field.value}
|
||||
onChange={(e): void => field.onChange(e.target.value)}
|
||||
testId="gcp-deployment-project-id-input"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentProjectIdField;
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { GCP_REGIONS } from 'container/Integrations/constants';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
|
||||
import FormField from '../FormField';
|
||||
import { GcpSetupFormValues } from '../types';
|
||||
|
||||
import styles from './Fields.module.scss';
|
||||
|
||||
const TOOLTIPS: Record<CloudintegrationtypesGCPConnectionMethodDTO, string> = {
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.manual]:
|
||||
'The GCP region where your OTel Collector will be deployed',
|
||||
[CloudintegrationtypesGCPConnectionMethodDTO.agent]:
|
||||
'The GCP region where the agent resources are deployed',
|
||||
};
|
||||
|
||||
const REGION_ITEMS = GCP_REGIONS.map((region) => ({
|
||||
value: region.value,
|
||||
label: `${region.label} (${region.value})`,
|
||||
}));
|
||||
|
||||
interface DeploymentRegionFieldProps {
|
||||
control: Control<GcpSetupFormValues>;
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}
|
||||
|
||||
function DeploymentRegionField({
|
||||
control,
|
||||
connectionMethod,
|
||||
}: DeploymentRegionFieldProps): JSX.Element {
|
||||
return (
|
||||
<Controller
|
||||
name="deploymentRegion"
|
||||
control={control}
|
||||
rules={{ required: 'Please select a region' }}
|
||||
render={({ field, fieldState }): JSX.Element => (
|
||||
<FormField
|
||||
htmlFor="gcp-deployment-region-select"
|
||||
label="Deployment Region"
|
||||
tooltip={TOOLTIPS[connectionMethod]}
|
||||
required
|
||||
error={fieldState.error}
|
||||
>
|
||||
<ComboboxSimple
|
||||
id="gcp-deployment-region-select"
|
||||
className={cx(styles.fullWidth, styles.regionSelect, {
|
||||
[styles.regionEmpty]: !field.value,
|
||||
})}
|
||||
items={REGION_ITEMS}
|
||||
value={field.value}
|
||||
onChange={(value): void => field.onChange(value as string)}
|
||||
placeholder="Select a region..."
|
||||
inputPlaceholder="Search regions…"
|
||||
withPortal={false}
|
||||
testId="gcp-deployment-region-select"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentRegionField;
|
||||
@@ -1,19 +0,0 @@
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.regionSelect {
|
||||
// Selected region value: bright, like every other field's text.
|
||||
[data-slot='combobox-value'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Empty trigger (.regionEmpty, set from the RHF value): dull the placeholder text.
|
||||
&.regionEmpty [data-slot='combobox-value'] {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Shared by both connection methods; agent setup leaves accountName and projectIds unused. */
|
||||
export type SetupFlow = 'manual' | 'agent';
|
||||
|
||||
export interface GcpSetupFormValues {
|
||||
accountName: string;
|
||||
deploymentProjectId: string;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { UseFormSetValue } from 'react-hook-form';
|
||||
|
||||
import { GcpSetupFormValues } from './types';
|
||||
|
||||
// Pre-fill the deployment/ingestion fields with the fetched credentials.
|
||||
export function usePrefillCredentials(
|
||||
connectionParams: CloudintegrationtypesCredentialsDTO | undefined,
|
||||
setValue: UseFormSetValue<GcpSetupFormValues>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!connectionParams) {
|
||||
return;
|
||||
}
|
||||
setValue('sigNozApiUrl', connectionParams.sigNozApiUrl);
|
||||
setValue('sigNozApiKey', connectionParams.sigNozApiKey);
|
||||
setValue('ingestionUrl', connectionParams.ingestionUrl);
|
||||
setValue('ingestionKey', connectionParams.ingestionKey);
|
||||
}, [connectionParams, setValue]);
|
||||
}
|
||||
@@ -3,15 +3,13 @@ import { useQueryClient } from 'react-query';
|
||||
import { Save } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Form } from 'antd';
|
||||
import { Form, Select } from 'antd';
|
||||
import { invalidateListAccounts } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
import { useAccountSettingsDrawer } from 'hooks/integration/gcp/useAccountSettingsDrawer';
|
||||
|
||||
import RemoveIntegrationAccount from '../../RemoveAccount/RemoveIntegrationAccount';
|
||||
import ProjectIdsInput from '../ProjectIdsInput';
|
||||
|
||||
import styles from './AccountSettingsDrawer.module.scss';
|
||||
|
||||
@@ -42,10 +40,6 @@ function AccountSettingsDrawer({
|
||||
() => ('project_ids' in account.config ? account.config : null),
|
||||
[account.config],
|
||||
);
|
||||
// The backend rejects updates to agent accounts, so their settings are read-only.
|
||||
const isAgentAccount =
|
||||
gcpConfig?.connection_method ===
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent;
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
@@ -71,23 +65,20 @@ function AccountSettingsDrawer({
|
||||
handleClose();
|
||||
}}
|
||||
cloudProvider={INTEGRATION_TYPES.GCP}
|
||||
gcpConnectionMethod={gcpConfig?.connection_method}
|
||||
/>
|
||||
{!isAgentAccount && (
|
||||
<Button
|
||||
disabledTooltip="Change the project IDs first"
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
testId="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabledTooltip="Change the project IDs first"
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
disabled={isSaveDisabled}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
prefix={<Save size={14} />}
|
||||
testId="gcp-update-account-btn"
|
||||
>
|
||||
Update Changes
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -131,42 +122,32 @@ function AccountSettingsDrawer({
|
||||
|
||||
<div className={styles.regionSelector}>
|
||||
<div className={styles.regionSelectorTitle}>Projects to monitor</div>
|
||||
{isAgentAccount ? (
|
||||
<div
|
||||
className={styles.regionSelectorDescription}
|
||||
data-testid="gcp-agent-monitored-projects"
|
||||
>
|
||||
{gcpConfig?.project_ids.join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.regionSelectorDescription}>
|
||||
Update the GCP project IDs that should be monitored.
|
||||
</div>
|
||||
<div className={styles.regionSelectorDescription}>
|
||||
Update the GCP project IDs that should be monitored.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ProjectIdsInput
|
||||
id="gcp-edit-project-ids-select"
|
||||
testId="gcp-edit-project-ids-select"
|
||||
value={projectIds}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
name="projectIds"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one project ID',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={projectIds}
|
||||
tokenSeparators={[',']}
|
||||
onChange={(values): void => {
|
||||
setProjectIds(values);
|
||||
form.setFieldValue('projectIds', values);
|
||||
}}
|
||||
data-testid="gcp-edit-project-ids-select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.projectIds {
|
||||
// The multi-value trigger is content-box, so full width needs its padding counted in.
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ProjectIdsInput.module.scss';
|
||||
|
||||
interface ProjectIdsInputProps {
|
||||
id: string;
|
||||
testId: string;
|
||||
value: string[];
|
||||
onChange: (projectIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Free-form list of GCP project IDs: type an ID and press Enter to add it. */
|
||||
function ProjectIdsInput({
|
||||
id,
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: ProjectIdsInputProps): JSX.Element {
|
||||
return (
|
||||
<ComboboxSimple
|
||||
id={id}
|
||||
testId={testId}
|
||||
className={cx(styles.projectIds, className)}
|
||||
multiple
|
||||
allowCreate
|
||||
value={value}
|
||||
onChange={(next): void => onChange(Array.isArray(next) ? next : [])}
|
||||
placeholder="Add project IDs…"
|
||||
inputPlaceholder="Type a project ID and press Enter"
|
||||
emptyPlaceholder="Type a project ID to add it"
|
||||
withPortal={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectIdsInput.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default ProjectIdsInput;
|
||||
@@ -1,18 +1,19 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
|
||||
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
GCP_ACCOUNT_ID,
|
||||
GCP_ACCOUNT_URL,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpAccount,
|
||||
gcpAccountConfig,
|
||||
listAccountsResponse,
|
||||
} from './mockData';
|
||||
import { addProjectIds } from './projectIdsTestUtils';
|
||||
|
||||
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
@@ -30,29 +31,31 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['onClose']
|
||||
>;
|
||||
const setActiveAccount = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['setActiveAccount']
|
||||
>;
|
||||
const onClose = jest.fn();
|
||||
const setActiveAccount = jest.fn();
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={gcpAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>,
|
||||
<MockQueryClientProvider>
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={gcpAccount}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
/** Each selected project ID renders as a pill with its own remove button. */
|
||||
const getProjectIdRemoveButtons = (): HTMLElement[] =>
|
||||
/** The antd tags Select renders its text input inside the testId wrapper. */
|
||||
const getProjectIdsInput = (): HTMLElement =>
|
||||
within(screen.getByTestId('gcp-edit-project-ids-select')).getByRole(
|
||||
'combobox',
|
||||
);
|
||||
|
||||
/** Each selected tag carries an antd "close" icon that removes it. */
|
||||
const getProjectIdTagRemoveButtons = (): HTMLElement[] =>
|
||||
within(screen.getByTestId('gcp-edit-project-ids-select')).queryAllByLabelText(
|
||||
/^Remove /,
|
||||
'close',
|
||||
);
|
||||
|
||||
describe('GCP AccountSettingsDrawer', () => {
|
||||
@@ -83,9 +86,8 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
screen.getByText(gcpAccountConfig.deployment_region),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const projectIdsInput = screen.getByTestId('gcp-edit-project-ids-select');
|
||||
gcpAccountConfig.project_ids.forEach((projectId) => {
|
||||
expect(projectIdsInput).toHaveTextContent(projectId);
|
||||
expect(screen.getByTitle(projectId)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +100,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
'true',
|
||||
);
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
await user.type(getProjectIdsInput(), 'project-c,');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
|
||||
@@ -112,7 +114,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
await user.type(getProjectIdsInput(), 'project-c,');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
@@ -139,7 +141,8 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
expect(setActiveAccount).toHaveBeenCalledWith({
|
||||
...gcpAccount,
|
||||
config: {
|
||||
...gcpAccountConfig,
|
||||
deployment_region: gcpAccountConfig.deployment_region,
|
||||
deployment_project_id: gcpAccountConfig.deployment_project_id,
|
||||
project_ids: ['project-a', 'project-b', 'project-c'],
|
||||
},
|
||||
});
|
||||
@@ -156,9 +159,9 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
renderDrawer();
|
||||
|
||||
// Strip every tag via its remove icon; the list shrinks as we go.
|
||||
while (getProjectIdRemoveButtons().length > 0) {
|
||||
while (getProjectIdTagRemoveButtons().length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await user.click(getProjectIdRemoveButtons()[0]);
|
||||
await user.click(getProjectIdTagRemoveButtons()[0]);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -191,7 +194,7 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await addProjectIds(user, 'gcp-edit-project-ids-select', ['project-c']);
|
||||
await user.type(getProjectIdsInput(), 'project-c,');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-update-account-btn')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
@@ -209,4 +212,31 @@ describe('GCP AccountSettingsDrawer', () => {
|
||||
expect(setActiveAccount).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects the account with the GCP-specific confirmation copy', async () => {
|
||||
let disconnectedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
|
||||
disconnectedId = req.params.id as string;
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/manually tear down/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
|
||||
});
|
||||
expect(setActiveAccount).toHaveBeenCalledWith(null);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { CloudAccount } from 'container/Integrations/types';
|
||||
|
||||
import AccountSettingsDrawer from '../EditAccount/AccountSettingsDrawer';
|
||||
import {
|
||||
GCP_ACCOUNT_ID,
|
||||
GCP_ACCOUNTS_URL,
|
||||
gcpAccount,
|
||||
gcpAgentAccount,
|
||||
listAccountsResponse,
|
||||
} from './mockData';
|
||||
|
||||
// `useAccountSettingsDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
type AccountSettingsDrawerProps = ComponentProps<typeof AccountSettingsDrawer>;
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['onClose']
|
||||
>;
|
||||
const setActiveAccount = jest.fn() as jest.MockedFunction<
|
||||
AccountSettingsDrawerProps['setActiveAccount']
|
||||
>;
|
||||
|
||||
const renderDrawer = (account: CloudAccount = gcpAccount): void => {
|
||||
render(
|
||||
<AccountSettingsDrawer
|
||||
onClose={onClose}
|
||||
account={account}
|
||||
setActiveAccount={setActiveAccount}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('GCP AccountSettingsDrawer by connection method', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listAccountsResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('disconnects the account with the GCP-specific confirmation copy', async () => {
|
||||
let disconnectedId: string | null = null;
|
||||
server.use(
|
||||
rest.delete(`${GCP_ACCOUNTS_URL}/:id`, (req, res, ctx) => {
|
||||
disconnectedId = req.params.id as string;
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/manually tear down/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /remove account/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(disconnectedId).toBe(GCP_ACCOUNT_ID);
|
||||
});
|
||||
expect(setActiveAccount).toHaveBeenCalledWith(null);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows agent accounts read-only, since the backend rejects updates to them', () => {
|
||||
renderDrawer(gcpAgentAccount);
|
||||
|
||||
expect(screen.getByTestId('gcp-agent-monitored-projects')).toHaveTextContent(
|
||||
'my-agent-project',
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('gcp-edit-project-ids-select'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-update-account-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains the agent-specific cleanup when disconnecting an agent account', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer(gcpAgentAccount);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /disconnect/i }));
|
||||
|
||||
await expect(
|
||||
screen.findByText(/signoz-integration-bootstrap Infrastructure/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(/manually tear down/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
connectedAgentAccountResponse,
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// The setup hooks import logEvent by relative path, which the jest.config
|
||||
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
|
||||
// intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
const generateAgentCommand = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> => {
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'my-deployment-project-123',
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
await screen.findByTestId('gcp-agent-command');
|
||||
};
|
||||
|
||||
describe('GCP setup drawer close confirmation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes right away when no setup command has been generated', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await user.click(screen.getByTestId('gcp-cancel-btn'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('gcp-close-setup-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('asks before cancelling a pending agent setup and can continue it', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommand(user);
|
||||
await user.click(screen.getByTestId('gcp-cancel-btn'));
|
||||
|
||||
await screen.findByText(/start the installation process again/i);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-continue-setup-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/start the installation process again/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('gcp-agent-command')).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the drawer once the user confirms', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommand(user);
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await user.click(await screen.findByTestId('gcp-close-setup-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes without asking once the agent connects', async () => {
|
||||
server.use(
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectedAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
await generateAgentCommand(user);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(
|
||||
screen.queryByText(/start the installation process again/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { RestRequest } from 'msw';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest, RestRequest } from 'msw';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
@@ -14,9 +15,8 @@ import {
|
||||
GCP_CHECK_IN_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
} from './mockData';
|
||||
import { addProjectIds } from './projectIdsTestUtils';
|
||||
|
||||
// `useManualAccountSetup` imports logEvent by relative path, which the
|
||||
// `useCloudAccountSetupDrawer` imports logEvent by relative path, which the
|
||||
// jest.config moduleNameMapper (keyed on the `api/common/logEvent` alias) does
|
||||
// not intercept — so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
@@ -24,12 +24,14 @@ jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
const onClose = jest.fn();
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
render(
|
||||
<MockQueryClientProvider>
|
||||
<CloudAccountSetupDrawer onClose={onClose} />
|
||||
</MockQueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('GCP CloudAccountSetupDrawer', () => {
|
||||
@@ -107,13 +109,6 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
});
|
||||
|
||||
it('creates the account, checks the agent in, and closes the drawer', async () => {
|
||||
// A slow check-in keeps the connection in flight long enough to observe it.
|
||||
server.use(
|
||||
rest.post(GCP_CHECK_IN_URL, async (req: RestRequest, res, ctx) => {
|
||||
checkInPayload = await req.json();
|
||||
return res(ctx.delay(300), ctx.status(200), ctx.json(checkInResponse));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
@@ -132,21 +127,13 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
|
||||
await addProjectIds(user, 'gcp-project-ids-select', [
|
||||
'project-a',
|
||||
'project-b',
|
||||
]);
|
||||
const projectIdsInput = document.querySelector(
|
||||
'#gcp-project-ids-select',
|
||||
) as HTMLInputElement;
|
||||
await user.type(projectIdsInput, 'project-a,project-b,');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
|
||||
// Switching away mid-request would let its success close the other form.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radiogroup')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createAccountPayload).not.toBeNull();
|
||||
});
|
||||
@@ -154,7 +141,6 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
expect(createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'manual',
|
||||
deploymentRegion: 'asia-south1',
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
projectIds: ['project-a', 'project-b'],
|
||||
@@ -207,7 +193,10 @@ describe('GCP CloudAccountSetupDrawer', () => {
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
|
||||
await addProjectIds(user, 'gcp-project-ids-select', ['project-a']);
|
||||
const projectIdsInput = document.querySelector(
|
||||
'#gcp-project-ids-select',
|
||||
) as HTMLInputElement;
|
||||
await user.type(projectIdsInput, 'project-a,');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CloudAccountSetupDrawer from '../AddNewAccount/CloudAccountSetupDrawer';
|
||||
import {
|
||||
connectionCredentialsResponse,
|
||||
createAgentAccountResponse,
|
||||
GCP_ACCOUNTS_URL,
|
||||
GCP_AGENT_ACCOUNT_URL,
|
||||
GCP_CREDENTIALS_URL,
|
||||
pendingAgentAccountResponse,
|
||||
} from './mockData';
|
||||
|
||||
// The setup hooks import logEvent by relative path, which the jest.config
|
||||
// moduleNameMapper (keyed on the `api/common/logEvent` alias) does not
|
||||
// intercept, so mock the resolved module directly.
|
||||
jest.mock('../../../../../api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const onClose = jest.fn() as jest.MockedFunction<
|
||||
ComponentProps<typeof CloudAccountSetupDrawer>['onClose']
|
||||
>;
|
||||
|
||||
const renderDrawer = (): void => {
|
||||
render(<CloudAccountSetupDrawer onClose={onClose} />);
|
||||
};
|
||||
|
||||
describe('GCP setup drawer connection method switch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(GCP_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(connectionCredentialsResponse)),
|
||||
),
|
||||
rest.post(GCP_ACCOUNTS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createAgentAccountResponse)),
|
||||
),
|
||||
rest.get(GCP_AGENT_ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(pendingAgentAccountResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('switches between the manual and agent connection methods inside the same drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
const drawer = screen.getByTestId('gcp-setup-drawer');
|
||||
expect(screen.getByTestId('gcp-account-name-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('gcp-connect-account-btn')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
|
||||
await screen.findByTestId('gcp-generate-command-btn');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-account-name-input'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('gcp-connect-account-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-manual'));
|
||||
|
||||
await screen.findByTestId('gcp-account-name-input');
|
||||
expect(
|
||||
screen.queryByTestId('gcp-generate-command-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The drawer itself stays mounted, so it does not replay its open animation.
|
||||
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
|
||||
});
|
||||
|
||||
it('keeps the drawer width the same across connection methods and setup steps', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDrawer();
|
||||
|
||||
const drawer = screen.getByTestId('gcp-setup-drawer');
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
|
||||
await user.click(screen.getByTestId('gcp-connection-method-agent'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gcp-generate-command-btn')).toBeEnabled();
|
||||
});
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'my-deployment-project-123',
|
||||
);
|
||||
await user.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await user.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await user.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
|
||||
await screen.findByTestId('gcp-agent-command');
|
||||
expect(screen.getByTestId('gcp-setup-drawer')).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute('data-width', 'extra-wide');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
CloudAccount,
|
||||
GCPCloudAccountConfig,
|
||||
@@ -20,7 +17,6 @@ export const GCP_ACCOUNT_ID = 'acc-gcp-1';
|
||||
export const GCP_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${GCP_ACCOUNT_ID}`;
|
||||
|
||||
export const gcpAccountConfig: GCPCloudAccountConfig = {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deployment_region: 'asia-south1',
|
||||
deployment_project_id: 'my-deployment-project-123',
|
||||
project_ids: ['project-a', 'project-b'],
|
||||
@@ -34,19 +30,6 @@ export const gcpAccount: CloudAccount = {
|
||||
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
|
||||
};
|
||||
|
||||
export const gcpAgentAccount: CloudAccount = {
|
||||
id: 'acc-gcp-agent-1',
|
||||
cloud_account_id: 'acc-gcp-agent-1',
|
||||
providerAccountId: 'my-agent-project',
|
||||
config: {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deployment_region: 'us-central1',
|
||||
deployment_project_id: 'my-agent-project',
|
||||
project_ids: ['my-agent-project'],
|
||||
},
|
||||
status: { integration: { last_heartbeat_ts_ms: 1_700_000_000_000 } },
|
||||
};
|
||||
|
||||
export const listAccountsResponse = {
|
||||
status: 'success',
|
||||
data: { accounts: [] },
|
||||
@@ -80,53 +63,3 @@ export const checkInResponse = {
|
||||
status: 'success',
|
||||
data: {},
|
||||
};
|
||||
|
||||
export const gcpConnectionCommand = {
|
||||
cliCommand:
|
||||
"curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform --gcp-project 'my-deployment-project-123'",
|
||||
};
|
||||
|
||||
export const createAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
id: CLOUD_INTEGRATION_ID,
|
||||
connectionArtifact: { gcp: gcpConnectionCommand },
|
||||
},
|
||||
};
|
||||
|
||||
export const GCP_AGENT_ACCOUNT_URL = `${GCP_ACCOUNTS_URL}/${CLOUD_INTEGRATION_ID}`;
|
||||
|
||||
const pendingAgentAccount = {
|
||||
id: CLOUD_INTEGRATION_ID,
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: null,
|
||||
removedAt: null,
|
||||
agentReport: null,
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: 'agent',
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
deploymentRegion: 'asia-south1',
|
||||
projectIds: ['my-deployment-project-123'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const pendingAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: pendingAgentAccount,
|
||||
};
|
||||
|
||||
export const connectedAgentAccountResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
...pendingAgentAccount,
|
||||
providerAccountId: 'my-deployment-project-123',
|
||||
agentReport: {
|
||||
timestampMillis: 1_700_000_000_000,
|
||||
data: {},
|
||||
syncState: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
/** Opens the project IDs input and adds each ID the way a user does: type, then Enter. */
|
||||
export const addProjectIds = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
testId: string,
|
||||
projectIds: string[],
|
||||
): Promise<void> => {
|
||||
await user.click(screen.getByTestId(testId));
|
||||
const input = await screen.findByPlaceholderText(
|
||||
'Type a project ID and press Enter',
|
||||
);
|
||||
for (const projectId of projectIds) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await user.type(input, `${projectId}{Enter}`);
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Modal } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useDisconnectAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import {
|
||||
INTEGRATION_TELEMETRY_EVENTS,
|
||||
@@ -18,12 +17,10 @@ function RemoveIntegrationAccount({
|
||||
cloudProvider,
|
||||
accountId,
|
||||
onRemoveIntegrationAccountSuccess,
|
||||
gcpConnectionMethod,
|
||||
}: {
|
||||
cloudProvider: string;
|
||||
accountId: string;
|
||||
onRemoveIntegrationAccountSuccess: () => void;
|
||||
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
}): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -47,7 +44,7 @@ function RemoveIntegrationAccount({
|
||||
},
|
||||
});
|
||||
const handleOk = (): void => {
|
||||
void logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
|
||||
logEvent(INTEGRATION_TELEMETRY_EVENTS.INTEGRATION_ACCOUNT_REMOVED, {
|
||||
accountId,
|
||||
integration: cloudProvider,
|
||||
});
|
||||
@@ -76,20 +73,6 @@ function RemoveIntegrationAccount({
|
||||
manually when connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (
|
||||
cloudProvider === INTEGRATION_TYPES.GCP &&
|
||||
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
|
||||
) {
|
||||
modalDescription = (
|
||||
<>
|
||||
Removing this account will stop SigNoz from monitoring it, and the agent
|
||||
will disable the telemetry resources it created in your GCP project. <br />
|
||||
<br />
|
||||
After that, you can delete the signoz-integration-bootstrap Infrastructure
|
||||
Manager deployment, the signoz-integration-bootstrap service account and the
|
||||
signoz-integration secrets that were created while connecting this account.
|
||||
</>
|
||||
);
|
||||
} else if (cloudProvider === INTEGRATION_TYPES.GCP) {
|
||||
modalDescription = (
|
||||
<>
|
||||
@@ -146,8 +129,4 @@ function RemoveIntegrationAccount({
|
||||
);
|
||||
}
|
||||
|
||||
RemoveIntegrationAccount.defaultProps = {
|
||||
gcpConnectionMethod: undefined,
|
||||
};
|
||||
|
||||
export default RemoveIntegrationAccount;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { mapAccountDtoToGcpCloudAccount } from '../mapCloudAccountFromDto';
|
||||
|
||||
const gcpAccountDto: CloudintegrationtypesAccountDTO = {
|
||||
id: 'acc-gcp-1',
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: 'my-project',
|
||||
removedAt: null,
|
||||
agentReport: { timestampMillis: 1_700_000_000_000, data: {}, syncState: null },
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentProjectId: 'my-project',
|
||||
deploymentRegion: 'us-central1',
|
||||
projectIds: ['my-project'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('mapAccountDtoToGcpCloudAccount', () => {
|
||||
it('maps the GCP config, including its connection method', () => {
|
||||
const account = mapAccountDtoToGcpCloudAccount(gcpAccountDto);
|
||||
|
||||
expect(account?.config).toStrictEqual({
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deployment_region: 'us-central1',
|
||||
deployment_project_id: 'my-project',
|
||||
project_ids: ['my-project'],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips an account that has no GCP config', () => {
|
||||
expect(
|
||||
mapAccountDtoToGcpCloudAccount({ ...gcpAccountDto, config: {} }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('skips an account the agent has not checked in for yet', () => {
|
||||
expect(
|
||||
mapAccountDtoToGcpCloudAccount({
|
||||
...gcpAccountDto,
|
||||
providerAccountId: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,7 @@ export function mapAccountDtoToAzureCloudAccount(
|
||||
export function mapAccountDtoToGcpCloudAccount(
|
||||
account: CloudintegrationtypesAccountDTO,
|
||||
): IntegrationCloudAccount | null {
|
||||
const gcpConfig = account.config.gcp;
|
||||
if (!account.providerAccountId || !gcpConfig) {
|
||||
if (!account.providerAccountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -60,10 +59,9 @@ export function mapAccountDtoToGcpCloudAccount(
|
||||
id: account.id,
|
||||
cloud_account_id: account.id,
|
||||
config: {
|
||||
connection_method: gcpConfig.connectionMethod,
|
||||
deployment_region: gcpConfig.deploymentRegion,
|
||||
deployment_project_id: gcpConfig.deploymentProjectId,
|
||||
project_ids: gcpConfig.projectIds,
|
||||
deployment_region: account.config?.gcp?.deploymentRegion ?? '',
|
||||
deployment_project_id: account.config?.gcp?.deploymentProjectId ?? '',
|
||||
project_ids: account.config?.gcp?.projectIds ?? [],
|
||||
},
|
||||
status: {
|
||||
integration: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AWSCloudAccountConfig,
|
||||
AWSServiceConfig,
|
||||
@@ -103,7 +102,6 @@ export interface AzureCloudAccountConfig {
|
||||
}
|
||||
|
||||
export interface GCPCloudAccountConfig {
|
||||
connection_method: CloudintegrationtypesGCPConnectionMethodDTO;
|
||||
deployment_region: string;
|
||||
deployment_project_id: string;
|
||||
project_ids: string[];
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
|
||||
type PollingProps = Parameters<typeof useAgentConnectionPolling>[0];
|
||||
|
||||
export const onConnected = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onConnected']
|
||||
>;
|
||||
export const onTimeout = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onTimeout']
|
||||
>;
|
||||
export const onError = jest.fn() as jest.MockedFunction<
|
||||
PollingProps['onError']
|
||||
>;
|
||||
|
||||
export const callbacks = { onConnected, onTimeout, onError };
|
||||
|
||||
export const ACCOUNT_ID = 'acc-gcp-1';
|
||||
export const ACCOUNT_URL = `http://localhost/api/v1/cloud_integrations/gcp/accounts/${ACCOUNT_ID}`;
|
||||
|
||||
const agentReport = {
|
||||
timestampMillis: 1_700_000_000_000,
|
||||
data: {},
|
||||
syncState: null,
|
||||
};
|
||||
|
||||
export const pendingAccount: CloudintegrationtypesAccountDTO = {
|
||||
id: ACCOUNT_ID,
|
||||
orgId: 'org-1',
|
||||
provider: 'gcp',
|
||||
providerAccountId: null,
|
||||
removedAt: null,
|
||||
agentReport: null,
|
||||
config: {
|
||||
gcp: {
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentProjectId: 'my-deployment-project-123',
|
||||
deploymentRegion: 'us-central1',
|
||||
projectIds: ['my-deployment-project-123'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const connectedAccount: CloudintegrationtypesAccountDTO = {
|
||||
...pendingAccount,
|
||||
providerAccountId: 'my-deployment-project-123',
|
||||
agentReport,
|
||||
};
|
||||
|
||||
export const mockAccountResponses = (
|
||||
...accounts: CloudintegrationtypesAccountDTO[]
|
||||
): { requestCount: () => number } => {
|
||||
let count = 0;
|
||||
server.use(
|
||||
rest.get(ACCOUNT_URL, (_req, res, ctx) => {
|
||||
const account = accounts[Math.min(count, accounts.length - 1)];
|
||||
count += 1;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: account }));
|
||||
}),
|
||||
);
|
||||
return { requestCount: (): number => count };
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import {
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
POLL_INTERVAL_MS,
|
||||
useAgentConnectionPolling,
|
||||
} from '../useAgentConnectionPolling';
|
||||
import {
|
||||
ACCOUNT_ID,
|
||||
ACCOUNT_URL,
|
||||
callbacks,
|
||||
connectedAccount,
|
||||
mockAccountResponses,
|
||||
onConnected,
|
||||
onError,
|
||||
onTimeout,
|
||||
pendingAccount,
|
||||
} from './pollingTestHelpers';
|
||||
|
||||
const renderPolling = (): void => {
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAgentConnectionPolling outcomes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps polling until the agent checks in, then reports the connection', async () => {
|
||||
const { requestCount } = mockAccountResponses(
|
||||
pendingAccount,
|
||||
pendingAccount,
|
||||
connectedAccount,
|
||||
);
|
||||
renderPolling();
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(POLL_INTERVAL_MS);
|
||||
});
|
||||
await waitFor(() => expect(requestCount()).toBe(2));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConnected).toHaveBeenCalledWith(connectedAccount),
|
||||
);
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat a removed account as connected', async () => {
|
||||
const { requestCount } = mockAccountResponses({
|
||||
...connectedAccount,
|
||||
removedAt: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
renderPolling();
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an error when the account request fails', async () => {
|
||||
server.use(
|
||||
rest.get(ACCOUNT_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
renderPolling();
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { act, AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
POLL_INTERVAL_MS,
|
||||
useAgentConnectionPolling,
|
||||
} from '../useAgentConnectionPolling';
|
||||
import {
|
||||
ACCOUNT_ID,
|
||||
callbacks,
|
||||
connectedAccount,
|
||||
mockAccountResponses,
|
||||
onConnected,
|
||||
onTimeout,
|
||||
pendingAccount,
|
||||
} from './pollingTestHelpers';
|
||||
|
||||
const renderPolling = (
|
||||
initialEnabled: boolean,
|
||||
): ((enabled: boolean) => void) => {
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }: { enabled: boolean }) =>
|
||||
useAgentConnectionPolling({
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled,
|
||||
...callbacks,
|
||||
}),
|
||||
{ wrapper: AllTheProviders, initialProps: { enabled: initialEnabled } },
|
||||
);
|
||||
return (enabled: boolean): void => rerender({ enabled });
|
||||
};
|
||||
|
||||
describe('useAgentConnectionPolling polling window', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not poll while disabled', async () => {
|
||||
const { requestCount } = mockAccountResponses(connectedAccount);
|
||||
renderPolling(false);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(POLL_INTERVAL_MS * 3);
|
||||
});
|
||||
|
||||
expect(requestCount()).toBe(0);
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a timeout once the deadline passes without a check-in', async () => {
|
||||
const { requestCount } = mockAccountResponses(pendingAccount);
|
||||
renderPolling(true);
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onTimeout).toHaveBeenCalled());
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the timeout clock when polling is enabled, not on mount', async () => {
|
||||
const { requestCount } = mockAccountResponses(pendingAccount);
|
||||
const setEnabled = renderPolling(false);
|
||||
|
||||
// Longer than the whole window passes before polling is turned on.
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(CONNECTION_TIMEOUT_MS + POLL_INTERVAL_MS);
|
||||
});
|
||||
setEnabled(true);
|
||||
|
||||
await waitFor(() => expect(requestCount()).toBe(1));
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,8 @@ export function useAccountSettingsDrawer({
|
||||
{
|
||||
onSuccess: () => {
|
||||
const nextConfig = {
|
||||
...accountConfig,
|
||||
deployment_region: accountConfig.deployment_region,
|
||||
deployment_project_id: accountConfig.deployment_project_id,
|
||||
project_ids: values.projectIds || [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
|
||||
import { useGcpAccountSetup } from './useGcpAccountSetup';
|
||||
|
||||
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
|
||||
|
||||
interface UseAgentAccountSetupProps {
|
||||
onClose: () => void;
|
||||
onCommandGenerated: () => void;
|
||||
}
|
||||
|
||||
interface UseAgentAccountSetup {
|
||||
isLoading: boolean;
|
||||
generateCommand: (values: GcpSetupFormValues) => Promise<void>;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
submitError: string | null;
|
||||
clearSubmitError: () => void;
|
||||
connectionCommand: string | null;
|
||||
/** The project the generated command targets, as submitted. */
|
||||
commandProjectId: string;
|
||||
modalState: ModalStateEnum;
|
||||
checkConnectionAgain: () => void;
|
||||
}
|
||||
|
||||
export function useAgentAccountSetup({
|
||||
onClose,
|
||||
onCommandGenerated,
|
||||
}: UseAgentAccountSetupProps): UseAgentAccountSetup {
|
||||
const {
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
isLoading,
|
||||
submitError,
|
||||
setSubmitError,
|
||||
clearSubmitError,
|
||||
createGcpAccount,
|
||||
runSubmit,
|
||||
handleConnected,
|
||||
} = useGcpAccountSetup({ onClose });
|
||||
|
||||
const [connectionCommand, setConnectionCommand] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [commandProjectId, setCommandProjectId] = useState('');
|
||||
const [accountId, setAccountId] = useState<string | null>(null);
|
||||
const [modalState, setModalState] = useState<ModalStateEnum>(
|
||||
ModalStateEnum.FORM,
|
||||
);
|
||||
|
||||
const generateCommand = useCallback(
|
||||
(values: GcpSetupFormValues): Promise<void> =>
|
||||
runSubmit(async () => {
|
||||
const account = await createGcpAccount(
|
||||
{
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
// The agent monitors only the project it is deployed in.
|
||||
projectIds: [values.deploymentProjectId],
|
||||
},
|
||||
values,
|
||||
);
|
||||
|
||||
const command = account.connectionArtifact.gcp?.cliCommand;
|
||||
if (!command) {
|
||||
setSubmitError(GENERATE_COMMAND_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
setAccountId(account.id);
|
||||
setCommandProjectId(values.deploymentProjectId);
|
||||
setConnectionCommand(command);
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
onCommandGenerated();
|
||||
|
||||
void logEvent('GCP Integration: Account connection command generated', {
|
||||
id: account.id,
|
||||
});
|
||||
}, GENERATE_COMMAND_ERROR),
|
||||
[createGcpAccount, onCommandGenerated, runSubmit, setSubmitError],
|
||||
);
|
||||
|
||||
const handleAgentConnected = useCallback(
|
||||
(account: CloudintegrationtypesAccountDTO): void => {
|
||||
handleConnected({
|
||||
cloudIntegrationId: account.id,
|
||||
providerAccountId: account.providerAccountId,
|
||||
});
|
||||
},
|
||||
[handleConnected],
|
||||
);
|
||||
|
||||
const handleTimeout = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
void logEvent('GCP Integration: Account connection attempt timed out', {
|
||||
id: accountId,
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
const handlePollingError = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
}, []);
|
||||
|
||||
const checkConnectionAgain = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
}, []);
|
||||
|
||||
useAgentConnectionPolling({
|
||||
accountId: accountId ?? '',
|
||||
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
|
||||
onConnected: handleAgentConnected,
|
||||
onTimeout: handleTimeout,
|
||||
onError: handlePollingError,
|
||||
});
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
generateCommand,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
connectionCommand,
|
||||
commandProjectId,
|
||||
modalState,
|
||||
checkConnectionAgain,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useGetAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
|
||||
export const POLL_INTERVAL_MS = 10 * 1000;
|
||||
export const CONNECTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
interface UseAgentConnectionPollingProps {
|
||||
accountId: string;
|
||||
/** Polls exactly while true; the caller decides when there is an account to poll. */
|
||||
enabled: boolean;
|
||||
onConnected: (account: CloudintegrationtypesAccountDTO) => void;
|
||||
onTimeout: () => void;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a GCP agent account until the agent checks in. The timeout is measured
|
||||
* from when polling gets enabled, so re-enabling it starts a fresh window.
|
||||
*/
|
||||
export function useAgentConnectionPolling({
|
||||
accountId,
|
||||
enabled,
|
||||
onConnected,
|
||||
onTimeout,
|
||||
onError,
|
||||
}: UseAgentConnectionPollingProps): void {
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
startTimeRef.current = enabled ? Date.now() : null;
|
||||
}, [enabled]);
|
||||
|
||||
useGetAccount(
|
||||
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
select: (response): CloudintegrationtypesAccountDTO => response.data,
|
||||
onSuccess: (account) => {
|
||||
const isConnected =
|
||||
Boolean(account.providerAccountId) && account.removedAt === null;
|
||||
|
||||
if (isConnected) {
|
||||
onConnected(account);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = startTimeRef.current ?? Date.now();
|
||||
if (Date.now() - startTime >= CONNECTION_TIMEOUT_MS) {
|
||||
onTimeout();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onError();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,16 @@ import { useCallback, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
CreateAccountMutationResult,
|
||||
GetConnectionCredentialsQueryResult,
|
||||
invalidateListAccounts,
|
||||
useAgentCheckIn,
|
||||
useCreateAccount,
|
||||
useGetConnectionCredentials,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPPostableAccountConfigDTO,
|
||||
CloudintegrationtypesGettableAccountWithConnectionArtifactDTO,
|
||||
CloudintegrationtypesPostableAccountDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
@@ -21,36 +22,23 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
|
||||
interface UseGcpAccountSetupProps {
|
||||
interface UseCloudAccountSetupDrawerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface UseGcpAccountSetup {
|
||||
interface UseCloudAccountSetupDrawer {
|
||||
isLoading: boolean;
|
||||
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
isLoading: boolean;
|
||||
submitError: string | null;
|
||||
setSubmitError: (message: string | null) => void;
|
||||
clearSubmitError: () => void;
|
||||
createGcpAccount: (
|
||||
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
|
||||
values: GcpSetupFormValues,
|
||||
) => Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO>;
|
||||
/** Runs a submit, showing the backend's message inline (or `fallbackMessage`) when it fails. */
|
||||
runSubmit: (
|
||||
work: () => Promise<void>,
|
||||
fallbackMessage: string,
|
||||
) => Promise<void>;
|
||||
handleConnected: (payload: {
|
||||
cloudIntegrationId: string;
|
||||
providerAccountId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/** What both GCP connection methods share: credentials, account creation, submit state and the connected toast. */
|
||||
export function useGcpAccountSetup({
|
||||
export function useCloudAccountSetupDrawer({
|
||||
onClose,
|
||||
}: UseGcpAccountSetupProps): UseGcpAccountSetup {
|
||||
}: UseCloudAccountSetupDrawerProps): UseCloudAccountSetupDrawer {
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
@@ -60,6 +48,7 @@ export function useGcpAccountSetup({
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createAccount } = useCreateAccount();
|
||||
const { mutateAsync: checkIn } = useAgentCheckIn();
|
||||
const handleError = useAxiosError();
|
||||
|
||||
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
|
||||
@@ -74,15 +63,44 @@ export function useGcpAccountSetup({
|
||||
},
|
||||
);
|
||||
|
||||
const createGcpAccount = useCallback(
|
||||
async (
|
||||
gcpConfig: CloudintegrationtypesGCPPostableAccountConfigDTO,
|
||||
values: GcpSetupFormValues,
|
||||
): Promise<CloudintegrationtypesGettableAccountWithConnectionArtifactDTO> => {
|
||||
const response = await createAccount({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: {
|
||||
config: { gcp: gcpConfig },
|
||||
const handleClose = useCallback((): void => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleConnectionSuccess = useCallback(
|
||||
(payload: {
|
||||
cloudIntegrationId: string;
|
||||
providerAccountId: string;
|
||||
}): void => {
|
||||
void logEvent('GCP Integration: Account connected', {
|
||||
cloudIntegrationId: payload.cloudIntegrationId,
|
||||
providerAccountId: payload.providerAccountId,
|
||||
});
|
||||
toast.success('GCP account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
void invalidateListAccounts(queryClient, {
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
});
|
||||
handleClose();
|
||||
},
|
||||
[handleClose, queryClient],
|
||||
);
|
||||
|
||||
const connectAccount = useCallback(
|
||||
async (values: GcpSetupFormValues): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const payload: CloudintegrationtypesPostableAccountDTO = {
|
||||
config: {
|
||||
gcp: {
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
projectIds: values.projectIds || [],
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
// Cloud users can't edit these — the backend-provided credentials are
|
||||
// authoritative. Enterprise users have no backend defaults and enter
|
||||
@@ -92,69 +110,56 @@ export function useGcpAccountSetup({
|
||||
sigNozApiUrl: connectionParams?.data?.sigNozApiUrl || values.sigNozApiUrl,
|
||||
sigNozApiKey: connectionParams?.data?.sigNozApiKey || values.sigNozApiKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[connectionParams, createAccount],
|
||||
);
|
||||
// Step 1: create the integration account.
|
||||
const createResponse: CreateAccountMutationResult = await createAccount({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
const runSubmit = useCallback(
|
||||
async (work: () => Promise<void>, fallbackMessage: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setSubmitError(null);
|
||||
await work();
|
||||
const cloudIntegrationId = createResponse.data.id;
|
||||
const providerAccountId = values.accountName;
|
||||
|
||||
void logEvent('GCP Integration: Account created', {
|
||||
id: cloudIntegrationId,
|
||||
});
|
||||
|
||||
// Step 2: mimic the agent by checking in from the frontend (manual flow).
|
||||
await checkIn({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: {
|
||||
providerAccountId,
|
||||
cloudIntegrationId,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
handleConnectionSuccess({ cloudIntegrationId, providerAccountId });
|
||||
} catch (error) {
|
||||
// Surface the backend's message inline in the drawer instead of a
|
||||
// generic failure string.
|
||||
const message = toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
fallbackMessage,
|
||||
'Failed to connect GCP account',
|
||||
).getErrorMessage();
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleConnected = useCallback(
|
||||
({
|
||||
cloudIntegrationId,
|
||||
providerAccountId,
|
||||
}: {
|
||||
cloudIntegrationId: string;
|
||||
providerAccountId: string | null;
|
||||
}): void => {
|
||||
void logEvent('GCP Integration: Account connected', {
|
||||
cloudIntegrationId,
|
||||
providerAccountId,
|
||||
});
|
||||
toast.success('GCP account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
void invalidateListAccounts(queryClient, {
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[onClose, queryClient],
|
||||
[connectionParams, createAccount, checkIn, handleConnectionSuccess],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
handleClose,
|
||||
connectionParams: connectionParams?.data as
|
||||
| CloudintegrationtypesCredentialsDTO
|
||||
| undefined,
|
||||
isConnectionParamsLoading,
|
||||
isLoading,
|
||||
submitError,
|
||||
setSubmitError,
|
||||
clearSubmitError,
|
||||
createGcpAccount,
|
||||
runSubmit,
|
||||
handleConnected,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAgentCheckIn } from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useGcpAccountSetup } from './useGcpAccountSetup';
|
||||
|
||||
interface UseManualAccountSetupProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface UseManualAccountSetup {
|
||||
isLoading: boolean;
|
||||
connectAccount: (values: GcpSetupFormValues) => Promise<void>;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
submitError: string | null;
|
||||
clearSubmitError: () => void;
|
||||
}
|
||||
|
||||
export function useManualAccountSetup({
|
||||
onClose,
|
||||
}: UseManualAccountSetupProps): UseManualAccountSetup {
|
||||
const {
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
isLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
createGcpAccount,
|
||||
runSubmit,
|
||||
handleConnected,
|
||||
} = useGcpAccountSetup({ onClose });
|
||||
|
||||
const { mutateAsync: checkIn } = useAgentCheckIn();
|
||||
|
||||
const connectAccount = useCallback(
|
||||
(values: GcpSetupFormValues): Promise<void> =>
|
||||
runSubmit(async () => {
|
||||
// Step 1: create the integration account.
|
||||
const account = await createGcpAccount(
|
||||
{
|
||||
connectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
deploymentRegion: values.deploymentRegion,
|
||||
deploymentProjectId: values.deploymentProjectId,
|
||||
projectIds: values.projectIds || [],
|
||||
},
|
||||
values,
|
||||
);
|
||||
|
||||
const cloudIntegrationId = account.id;
|
||||
const providerAccountId = values.accountName;
|
||||
|
||||
void logEvent('GCP Integration: Account created', {
|
||||
id: cloudIntegrationId,
|
||||
});
|
||||
|
||||
// Step 2: mimic the agent by checking in from the frontend (manual flow).
|
||||
await checkIn({
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.GCP },
|
||||
data: {
|
||||
providerAccountId,
|
||||
cloudIntegrationId,
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
|
||||
handleConnected({ cloudIntegrationId, providerAccountId });
|
||||
}, 'Failed to connect GCP account'),
|
||||
[checkIn, createGcpAccount, handleConnected, runSubmit],
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
connectAccount,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
submitError,
|
||||
clearSubmitError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const UNCONSTRAINED_ROUTE = '/unconstrained';
|
||||
|
||||
const renderOn = (
|
||||
compositeQuery: string,
|
||||
route: string = UNCONSTRAINED_ROUTE,
|
||||
): Query | null => {
|
||||
const search = new URLSearchParams();
|
||||
search.set('compositeQuery', compositeQuery);
|
||||
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`${route}?${search}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
return result.current;
|
||||
};
|
||||
|
||||
const logsWith = (item: object): string =>
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
|
||||
},
|
||||
});
|
||||
|
||||
const withHavingConverted = (queryData: object[]): object[] =>
|
||||
queryData.map((item) => ({ ...item, having: { expression: '' } }));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('treats a non-object or broken compositeQuery as no query', () => {
|
||||
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
|
||||
expect(renderOn(raw)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs the builder shape and converts the legacy having', () => {
|
||||
const defaultQueryData = withHavingConverted(
|
||||
initialQueryState.builder.queryData,
|
||||
);
|
||||
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
|
||||
(queryData) =>
|
||||
expect(
|
||||
renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData },
|
||||
}),
|
||||
)?.builder.queryData,
|
||||
).toStrictEqual(defaultQueryData),
|
||||
);
|
||||
|
||||
expect(
|
||||
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
|
||||
).toStrictEqual({ expression: '' });
|
||||
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('gives an id-less query with a filter the same id on every parse', () => {
|
||||
const { id: _, ...withoutId } = JSON.parse(
|
||||
logsWith({ filter: { expression: "service.name='x'" } }),
|
||||
);
|
||||
const first = renderOn(JSON.stringify(withoutId));
|
||||
const again = renderOn(JSON.stringify(withoutId));
|
||||
const other = renderOn(
|
||||
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(first?.id).toHaveLength(36);
|
||||
expect(again?.id).toBe(first?.id);
|
||||
expect(other?.id).not.toBe(first?.id);
|
||||
});
|
||||
|
||||
it('applies the page constraints on every constrained route', () => {
|
||||
const clickhouseWithOperator = JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
});
|
||||
|
||||
[
|
||||
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
|
||||
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
|
||||
].forEach(([route, dataSource, source]) => {
|
||||
const query = renderOn(clickhouseWithOperator, route);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
|
||||
expect(query?.builder.queryData[0].source ?? '').toBe(source);
|
||||
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the query type on a route without constraints', () => {
|
||||
const query = renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.metrics,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
|
||||
});
|
||||
});
|
||||
297
frontend/src/hooks/queryBuilder/compositeQuerySchema.test.ts
Normal file
297
frontend/src/hooks/queryBuilder/compositeQuerySchema.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
buildPageQuerySchema,
|
||||
normalizeCompositeQuery,
|
||||
} from './compositeQuerySchema';
|
||||
|
||||
const logsSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
const tracesSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
traceOperator: true,
|
||||
});
|
||||
const meterSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
});
|
||||
const aiSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
});
|
||||
|
||||
const builderlessUrlQuery = {
|
||||
queryType: 'clickhouse_sql',
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const logsItem = (overrides: object = {}): object => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const queryOf = (
|
||||
base: typeof initialQueriesMap.logs,
|
||||
queryData: unknown[],
|
||||
builder: object = {},
|
||||
): object => ({
|
||||
...base,
|
||||
builder: { ...base.builder, queryData, ...builder },
|
||||
});
|
||||
|
||||
const logsQuery = (queryData: unknown[], builder?: object): object =>
|
||||
queryOf(initialQueriesMap.logs, queryData, builder);
|
||||
|
||||
const without = (item: object, key: keyof IBuilderQuery): object => {
|
||||
const { [key]: _, ...rest } = item as IBuilderQuery;
|
||||
return rest;
|
||||
};
|
||||
|
||||
describe('normalizeCompositeQuery', () => {
|
||||
it('passes valid queries through unchanged', () => {
|
||||
const withoutSource = logsQuery([
|
||||
without(initialQueriesMap.logs.builder.queryData[0], 'source'),
|
||||
]);
|
||||
const clickhouse = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
const unmodeled = normalizeCompositeQuery({
|
||||
...logsQuery([logsItem({ builderQueryType: QueryMode.AI_QUERY_BUILDER })]),
|
||||
extra: 'kept',
|
||||
}).query;
|
||||
|
||||
expect(normalizeCompositeQuery(initialQueriesMap.logs)).toStrictEqual({
|
||||
query: initialQueriesMap.logs,
|
||||
changed: false,
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.logs, logsSchema),
|
||||
).toStrictEqual({ query: initialQueriesMap.logs, changed: false });
|
||||
expect(normalizeCompositeQuery(withoutSource, logsSchema).changed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
clickhouse,
|
||||
buildPageQuerySchema({ dataSource: DataSource.LOGS }),
|
||||
),
|
||||
).toStrictEqual({ query: clickhouse, changed: false });
|
||||
expect(unmodeled.builder.queryData[0].builderQueryType).toBe(
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
);
|
||||
expect((unmodeled as { extra?: string }).extra).toBe('kept');
|
||||
});
|
||||
|
||||
it('fills missing blocks with defaults and a stable id', () => {
|
||||
const sharedBefore = JSON.stringify(initialQueryState.builder);
|
||||
const builderless = normalizeCompositeQuery(builderlessUrlQuery);
|
||||
builderless.query.builder.queryFormulas.push(
|
||||
{} as (typeof builderless.query.builder.queryFormulas)[number],
|
||||
);
|
||||
const noArrays = normalizeCompositeQuery({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { queryData: initialQueriesMap.logs.builder.queryData },
|
||||
});
|
||||
const omittedBlocks = normalizeCompositeQuery(
|
||||
{
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [logsItem()], queryFormulas: [] },
|
||||
},
|
||||
logsSchema,
|
||||
);
|
||||
const { id: _, ...withoutId } = initialQueriesMap.logs;
|
||||
const id = normalizeCompositeQuery(withoutId).query.id;
|
||||
|
||||
expect(builderless.query.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(builderless.query.id).toBe(builderlessUrlQuery.id);
|
||||
expect(builderless.changed).toBe(true);
|
||||
expect(JSON.stringify(initialQueryState.builder)).toBe(sharedBefore);
|
||||
expect(noArrays.query.builder.queryFormulas).toStrictEqual([]);
|
||||
expect(noArrays.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(noArrays.changed).toBe(true);
|
||||
[[], [5, logsItem({ queryName: 'B' })]].forEach((queryData) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(logsQuery(queryData)).query.builder.queryData,
|
||||
).toStrictEqual(initialQueryState.builder.queryData),
|
||||
);
|
||||
expect(omittedBlocks.query.promql).toStrictEqual(initialQueryState.promql);
|
||||
expect(omittedBlocks.query.clickhouse_sql).toStrictEqual(
|
||||
initialQueryState.clickhouse_sql,
|
||||
);
|
||||
expect(omittedBlocks.query.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
expect(id).toHaveLength(36);
|
||||
expect(normalizeCompositeQuery(withoutId).query.id).toBe(id);
|
||||
expect(
|
||||
normalizeCompositeQuery({ ...withoutId, queryType: EQueryType.PROM }).query
|
||||
.id,
|
||||
).not.toBe(id);
|
||||
});
|
||||
|
||||
it('resets query types and text the page cannot run', () => {
|
||||
const builderless = normalizeCompositeQuery(builderlessUrlQuery, logsSchema);
|
||||
const withText = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
|
||||
},
|
||||
logsSchema,
|
||||
);
|
||||
|
||||
expect(builderless.query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(builderless.query.builder.queryData).toStrictEqual([
|
||||
{ ...initialQueryBuilderFormValuesMap.logs, source: '' },
|
||||
]);
|
||||
expect(withText.query.clickhouse_sql).toStrictEqual(
|
||||
initialQueryState.clickhouse_sql,
|
||||
);
|
||||
expect(withText.query.promql).toStrictEqual(initialQueryState.promql);
|
||||
expect(withText.changed).toBe(true);
|
||||
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...initialQueriesMap.traces, queryType },
|
||||
tracesSchema,
|
||||
).query.queryType,
|
||||
).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets items that do not match the page and keeps their names', () => {
|
||||
const mixed = normalizeCompositeQuery(
|
||||
logsQuery([
|
||||
logsItem({
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
filter: { expression: "name='GET /api'" },
|
||||
}),
|
||||
without(
|
||||
logsItem({ filter: { expression: "severity='error'" } }),
|
||||
'dataSource',
|
||||
),
|
||||
]),
|
||||
logsSchema,
|
||||
).query.builder.queryData;
|
||||
const meterFilled = normalizeCompositeQuery(
|
||||
queryOf(initialQueriesMap.metrics, [
|
||||
{
|
||||
...without(initialQueriesMap.metrics.builder.queryData[0], 'source'),
|
||||
filter: { expression: "x='y'" },
|
||||
},
|
||||
]),
|
||||
meterSchema,
|
||||
).query.builder.queryData[0];
|
||||
const metricsOnMeter = normalizeCompositeQuery(
|
||||
initialQueriesMap.metrics,
|
||||
meterSchema,
|
||||
);
|
||||
const tracesItem = {
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
filter: { expression: "name='llm'" },
|
||||
};
|
||||
const [aiMissing, aiWrong, aiLogs] = [
|
||||
[tracesItem],
|
||||
[
|
||||
{
|
||||
...tracesItem,
|
||||
queryName: 'B',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
},
|
||||
],
|
||||
[logsItem({ queryName: 'C' })],
|
||||
].map(
|
||||
(queryData) =>
|
||||
normalizeCompositeQuery(
|
||||
queryOf(initialQueriesMap.traces, queryData),
|
||||
aiSchema,
|
||||
).query.builder.queryData[0],
|
||||
);
|
||||
|
||||
expect(mixed[0]).toStrictEqual({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
source: '',
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
expect(mixed[1]).toMatchObject({
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression: "severity='error'" },
|
||||
});
|
||||
expect(meterFilled).toMatchObject({
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
filter: { expression: "x='y'" },
|
||||
});
|
||||
expect(metricsOnMeter.query.builder.queryData[0].source).toBe(
|
||||
TelemetrytypesSourceDTO.meter,
|
||||
);
|
||||
expect(metricsOnMeter.changed).toBe(true);
|
||||
expect(aiMissing).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
filter: { expression: "name='llm'" },
|
||||
});
|
||||
expect(aiWrong).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
queryName: 'B',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
expect(aiLogs).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'C',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a trace operator unless the page allows it', () => {
|
||||
const operator = {
|
||||
queryTraceOperator: [initialQueryBuilderFormValuesMap.traces],
|
||||
};
|
||||
const onLogs = normalizeCompositeQuery(
|
||||
logsQuery([logsItem()], operator),
|
||||
logsSchema,
|
||||
);
|
||||
const onTraces = normalizeCompositeQuery(
|
||||
queryOf(
|
||||
initialQueriesMap.traces,
|
||||
initialQueriesMap.traces.builder.queryData,
|
||||
operator,
|
||||
),
|
||||
tracesSchema,
|
||||
);
|
||||
|
||||
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(onLogs.changed).toBe(true);
|
||||
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
|
||||
expect(onTraces.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
134
frontend/src/hooks/queryBuilder/compositeQuerySchema.ts
Normal file
134
frontend/src/hooks/queryBuilder/compositeQuerySchema.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, omitBy } from 'lodash-es';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
|
||||
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
|
||||
|
||||
export type QueryConstraints = {
|
||||
dataSource: DataSource;
|
||||
source?: BuilderQuerySource;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
traceOperator?: boolean;
|
||||
queryTypes?: EQueryType[];
|
||||
};
|
||||
|
||||
export type NormalizedQuery = {
|
||||
query: Query;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
const fallback = (value: unknown) => (): never => cloneDeep(value) as never;
|
||||
|
||||
const builderItemSchema = z
|
||||
.looseObject({
|
||||
dataSource: z.enum(DataSource).optional().catch(undefined),
|
||||
source: z.string().optional().catch(undefined),
|
||||
})
|
||||
.transform((item) => omitBy(item, (value) => value === undefined));
|
||||
|
||||
const textItemSchema = z.looseObject({ query: z.string().catch('') });
|
||||
|
||||
const builderSchema = z.looseObject({
|
||||
queryData: z
|
||||
.array(builderItemSchema)
|
||||
.min(1)
|
||||
.catch(fallback(initialQueryState.builder.queryData)),
|
||||
queryFormulas: z.unknown().transform((value) => value ?? []),
|
||||
queryTraceOperator: z.unknown().transform((value) => value ?? []),
|
||||
});
|
||||
|
||||
const compositeQuerySchema = z.looseObject({
|
||||
queryType: z.enum(EQueryType).catch(fallback(EQueryType.QUERY_BUILDER)),
|
||||
builder: builderSchema.catch(fallback(initialQueryState.builder)),
|
||||
promql: z.array(textItemSchema).catch(fallback(initialQueryState.promql)),
|
||||
clickhouse_sql: z
|
||||
.array(textItemSchema)
|
||||
.catch(fallback(initialQueryState.clickhouse_sql)),
|
||||
});
|
||||
|
||||
const mustBeEmpty = (value: unknown): z.ZodType =>
|
||||
z.array(z.looseObject({ query: z.literal('') })).catch(fallback(value));
|
||||
|
||||
export function buildPageQuerySchema(
|
||||
constraints: QueryConstraints,
|
||||
): z.ZodType<unknown> {
|
||||
const { queryTypes } = constraints;
|
||||
const itemDefault: IBuilderQuery = {
|
||||
...initialQueryBuilderFormValuesMap[constraints.dataSource],
|
||||
source: constraints.source ?? '',
|
||||
...(constraints.builderQueryType && {
|
||||
builderQueryType: constraints.builderQueryType,
|
||||
}),
|
||||
};
|
||||
const allows = (type: EQueryType): boolean =>
|
||||
!queryTypes || queryTypes.includes(type);
|
||||
|
||||
const pageItemSchema = builderItemSchema
|
||||
.transform((item) => ({
|
||||
...item,
|
||||
dataSource: item.dataSource ?? constraints.dataSource,
|
||||
...(constraints.source && { source: item.source ?? constraints.source }),
|
||||
...(constraints.builderQueryType && {
|
||||
builderQueryType: item.builderQueryType ?? constraints.builderQueryType,
|
||||
}),
|
||||
}))
|
||||
.refine(
|
||||
(item) =>
|
||||
item.dataSource === constraints.dataSource &&
|
||||
(item.source ?? '') === (constraints.source ?? '') &&
|
||||
(!constraints.builderQueryType ||
|
||||
item.builderQueryType === constraints.builderQueryType),
|
||||
)
|
||||
.catch(({ input }): never => {
|
||||
const { queryName, expression } = input as Partial<IBuilderQuery>;
|
||||
return {
|
||||
...cloneDeep(itemDefault),
|
||||
queryName: queryName ?? itemDefault.queryName,
|
||||
expression: expression ?? itemDefault.expression,
|
||||
} as never;
|
||||
});
|
||||
|
||||
return compositeQuerySchema.extend({
|
||||
queryType: queryTypes
|
||||
? z
|
||||
.enum(EQueryType)
|
||||
.refine((type) => queryTypes.includes(type))
|
||||
.catch(fallback(queryTypes[0]))
|
||||
: compositeQuerySchema.shape.queryType,
|
||||
promql: allows(EQueryType.PROM)
|
||||
? compositeQuerySchema.shape.promql
|
||||
: mustBeEmpty(initialQueryState.promql),
|
||||
clickhouse_sql: allows(EQueryType.CLICKHOUSE)
|
||||
? compositeQuerySchema.shape.clickhouse_sql
|
||||
: mustBeEmpty(initialQueryState.clickhouse_sql),
|
||||
builder: builderSchema
|
||||
.extend({
|
||||
queryData: z
|
||||
.array(pageItemSchema)
|
||||
.min(1)
|
||||
.catch(fallback([itemDefault])),
|
||||
queryTraceOperator: constraints.traceOperator
|
||||
? builderSchema.shape.queryTraceOperator
|
||||
: z.array(z.never()).catch(fallback([])),
|
||||
})
|
||||
.catch(fallback({ ...initialQueryState.builder, queryData: [itemDefault] })),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCompositeQuery(
|
||||
raw: unknown,
|
||||
pageSchema?: z.ZodType<unknown>,
|
||||
): NormalizedQuery {
|
||||
const base = compositeQuerySchema.parse(raw) as unknown as Query;
|
||||
base.id ??= uuidv5(JSON.stringify(raw), COMPOSITE_QUERY_ID_NAMESPACE);
|
||||
const query = (pageSchema ? pageSchema.parse(base) : base) as Query;
|
||||
return { query, changed: !isEqual(query, raw) };
|
||||
}
|
||||
45
frontend/src/hooks/queryBuilder/pageQueryConstraints.ts
Normal file
45
frontend/src/hooks/queryBuilder/pageQueryConstraints.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildPageQuerySchema, QueryConstraints } from './compositeQuerySchema';
|
||||
|
||||
const PAGE_QUERY_CONSTRAINTS: Partial<Record<string, QueryConstraints>> = {
|
||||
[ROUTES.LOGS_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.LOGS,
|
||||
},
|
||||
[ROUTES.TRACES_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
traceOperator: true,
|
||||
},
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
},
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
[ROUTES.METER_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
};
|
||||
|
||||
const pageSchemas = new Map<string, z.ZodType<unknown>>(
|
||||
Object.entries(PAGE_QUERY_CONSTRAINTS).map(([route, constraints]) => [
|
||||
route,
|
||||
buildPageQuerySchema(constraints as QueryConstraints),
|
||||
]),
|
||||
);
|
||||
|
||||
export const pageQuerySchemaFor = (
|
||||
pathname: string,
|
||||
): z.ZodType<unknown> | undefined => pageSchemas.get(pathname);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
@@ -6,15 +7,24 @@ import {
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isPlainObject } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
import {
|
||||
NormalizedQuery,
|
||||
normalizeCompositeQuery,
|
||||
} from './compositeQuerySchema';
|
||||
import { pageQuerySchemaFor } from './pageQueryConstraints';
|
||||
|
||||
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
let changed = false;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
@@ -27,13 +37,10 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
}
|
||||
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
|
||||
parsedCompositeQuery,
|
||||
pageQuerySchemaFor(pathname),
|
||||
));
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
@@ -51,7 +58,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
const convertedHaving = convertHavingToExpression(
|
||||
query.having.filter(isPlainObject),
|
||||
);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
@@ -75,6 +84,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
|
||||
}, [urlQuery, pathname]);
|
||||
};
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null =>
|
||||
useNormalizedCompositeQueryParam()?.query ?? null;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export function replaceIncorrectObjectFields<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
TargetValue extends object,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ResultValue extends object,
|
||||
>(
|
||||
targetObject: TargetValue,
|
||||
defaultObject: ResultValue,
|
||||
): { isValid: boolean; validData: ResultValue } {
|
||||
const targetObjectKeys = Object.keys(targetObject);
|
||||
const defaultObjectKeys = Object.keys(defaultObject);
|
||||
|
||||
let isValid = true;
|
||||
|
||||
const result: ResultValue = { ...defaultObject };
|
||||
|
||||
defaultObjectKeys.forEach((key) => {
|
||||
if (targetObjectKeys.includes(key)) {
|
||||
result[key as keyof ResultValue] = targetObject[
|
||||
key as keyof TargetValue
|
||||
] as unknown as ResultValue[keyof ResultValue];
|
||||
} else {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, validData: result };
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { usePlotContext } from '../../context/PlotContext';
|
||||
import { UPlotChartProps } from '../types';
|
||||
import { prepareAlignedData, setInstanceHandle } from './utils';
|
||||
import { prepareAlignedData } from './utils';
|
||||
|
||||
/**
|
||||
* Check if dimensions have changed
|
||||
@@ -58,7 +58,6 @@ export default function UPlotChart({
|
||||
onDestroy?.(plotInstanceRef.current);
|
||||
plotInstanceRef.current.destroy();
|
||||
plotInstanceRef.current = null;
|
||||
setInstanceHandle(containerRef.current, null);
|
||||
setPlotContextInitialState({ uPlotInstance: null });
|
||||
plotRef?.(null);
|
||||
}
|
||||
@@ -102,7 +101,6 @@ export default function UPlotChart({
|
||||
});
|
||||
|
||||
plotInstanceRef.current = plot;
|
||||
setInstanceHandle(containerRef.current, plot);
|
||||
}, [
|
||||
config,
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
export function prepareAlignedData({
|
||||
data,
|
||||
@@ -14,33 +14,3 @@ export function prepareAlignedData({
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live uPlot instance, hung off its own container element.
|
||||
*
|
||||
* Charts render to a canvas, so from the outside there is nothing to assert on
|
||||
* beyond pixels — an E2E test can see that a chart exists but not that a series
|
||||
* was hidden, an axis bound applied, or a line turned dashed. Exposing the
|
||||
* instance on its container gives those tests (and anyone debugging in the
|
||||
* console) the real chart state to read.
|
||||
*
|
||||
* On the container rather than a global: it is naturally scoped per panel, it
|
||||
* needs no registry keyed by id, and it is collected along with the node.
|
||||
*/
|
||||
export interface UPlotHostElement extends HTMLDivElement {
|
||||
__uplot?: uPlot;
|
||||
}
|
||||
|
||||
export function setInstanceHandle(
|
||||
node: HTMLDivElement | null,
|
||||
plot: uPlot | null,
|
||||
): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (plot) {
|
||||
(node as UPlotHostElement).__uplot = plot;
|
||||
} else {
|
||||
delete (node as UPlotHostElement).__uplot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function AddColumnDropdown({
|
||||
value={field.name}
|
||||
isSelected={selectedNames.has(field.name)}
|
||||
onSelect={(): void => onToggle(field)}
|
||||
data-testid={`list-columns-suggestion-${field.name}`}
|
||||
data-testid="list-columns-suggestion"
|
||||
>
|
||||
{field.name}
|
||||
</ComboboxItem>
|
||||
|
||||
@@ -37,12 +37,7 @@ function SortableColumnChip({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.chip}
|
||||
data-testid={`list-column-chip-${name}`}
|
||||
>
|
||||
<div ref={setNodeRef} style={style} className={styles.chip}>
|
||||
<span {...listeners} className={styles.grip}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -66,7 +61,7 @@ function SortableColumnChip({
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={`Remove ${name}`}
|
||||
testId={`list-column-remove-${name}`}
|
||||
testId="list-column-remove"
|
||||
onClick={(): void => onRemove(name)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -88,7 +88,6 @@ function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
data-testid="preview-pane"
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
|
||||
@@ -16,8 +16,6 @@ export interface ResizableHeaderProps extends Omit<
|
||||
> {
|
||||
width?: number;
|
||||
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
|
||||
/** Column key, used only to give the drag grip a stable test handle. */
|
||||
columnKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +27,6 @@ export interface ResizableHeaderProps extends Omit<
|
||||
function ResizableHeader({
|
||||
width,
|
||||
onResize,
|
||||
columnKey,
|
||||
...restProps
|
||||
}: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
@@ -37,14 +34,13 @@ function ResizableHeader({
|
||||
<span
|
||||
className={styles.handle}
|
||||
role="presentation"
|
||||
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
|
||||
// Stop the grip's click from reaching the column sorter underneath.
|
||||
// The grip is a pointer-only resize affordance, not keyboard-actionable.
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
[columnKey],
|
||||
[],
|
||||
);
|
||||
|
||||
if (!width || !onResize) {
|
||||
@@ -68,7 +64,6 @@ function ResizableHeader({
|
||||
ResizableHeader.defaultProps = {
|
||||
width: undefined,
|
||||
onResize: undefined,
|
||||
columnKey: undefined,
|
||||
};
|
||||
|
||||
export default ResizableHeader;
|
||||
|
||||
@@ -132,7 +132,6 @@ export function useResizableColumns<T>({
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
columnKey: key,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
|
||||
@@ -104,9 +104,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
|
||||
{name}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
|
||||
{description && (
|
||||
<Tooltip title={description}>
|
||||
<Info
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
await screen.findAllByTestId(/^list-columns-suggestion-/);
|
||||
await screen.findAllByTestId('list-columns-suggestion');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
type CloudintegrationtypesPostableAccountDTO,
|
||||
type CloudintegrationtypesUpdatableServiceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { CloudintegrationtypesUpdatableServiceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
@@ -22,14 +18,9 @@ import {
|
||||
CLOUD_PROVIDERS,
|
||||
CLOUD_SERVICE_CAP,
|
||||
type CloudProvider,
|
||||
GCP_CONNECTION_METHODS,
|
||||
MUTATION_STATES,
|
||||
type MutationState,
|
||||
NEW_ACCOUNT_CHECK_INS,
|
||||
type NewAccountCheckIn,
|
||||
createdAccountResponse,
|
||||
newAccountId,
|
||||
newAccountResponse,
|
||||
BUILT_IN_INTEGRATION_NOT_FOUND,
|
||||
credentialsResponse,
|
||||
seedServiceSignals,
|
||||
@@ -45,11 +36,6 @@ import {
|
||||
const builtInIntegrationNotFound: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json(BUILT_IN_INTEGRATION_NOT_FOUND));
|
||||
|
||||
const FAILED_REQUEST = {
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'Request failed' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers a mutation by the control's state: never, with a failure, or with the
|
||||
* success body the caller builds.
|
||||
@@ -62,33 +48,16 @@ const mutationResolver =
|
||||
}
|
||||
|
||||
return state === 'error'
|
||||
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
|
||||
? res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'Request failed' },
|
||||
}),
|
||||
)
|
||||
: ok(req, res, ctx, ...rest);
|
||||
};
|
||||
|
||||
/**
|
||||
* The account the connect flow just created answers by the check-in control;
|
||||
* every other account is the page's data.
|
||||
*/
|
||||
const accountResolver =
|
||||
(
|
||||
provider: CloudProvider,
|
||||
checkIn: NewAccountCheckIn,
|
||||
existing: MockResolver,
|
||||
): MockResolver =>
|
||||
(req, res, ctx, ...rest) => {
|
||||
if (String(req.params.id) !== newAccountId(provider)) {
|
||||
return existing(req, res, ctx, ...rest);
|
||||
}
|
||||
|
||||
return checkIn === 'error'
|
||||
? res(ctx.status(500), ctx.json(FAILED_REQUEST))
|
||||
: res(
|
||||
ctx.status(200),
|
||||
ctx.json(newAccountResponse(provider, checkIn === 'checked-in')),
|
||||
);
|
||||
};
|
||||
|
||||
const PROVIDER = 'Cloud integration · provider';
|
||||
const MUTATIONS = 'Cloud integration · mutations';
|
||||
const SERVICES = 'Cloud integration · services';
|
||||
@@ -102,17 +71,6 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
options: CLOUD_PROVIDERS,
|
||||
value: 'aws',
|
||||
}),
|
||||
gcpConnectionMethod:
|
||||
choiceControl<CloudintegrationtypesGCPConnectionMethodDTO>(
|
||||
'GCP connection method',
|
||||
{
|
||||
group: PROVIDER,
|
||||
description:
|
||||
'How the connected GCP accounts were set up. `agent` makes the account settings drawer read-only and switches the Disconnect copy to the agent cleanup.',
|
||||
options: GCP_CONNECTION_METHODS,
|
||||
value: CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
},
|
||||
),
|
||||
accounts: countControl('Connected accounts', {
|
||||
group: PROVIDER,
|
||||
description:
|
||||
@@ -141,13 +99,6 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
options: MUTATION_STATES,
|
||||
value: 'success',
|
||||
}),
|
||||
newAccountCheckIn: choiceControl<NewAccountCheckIn>('New account check-in', {
|
||||
group: MUTATIONS,
|
||||
description:
|
||||
'How the poll for the account the connect flow just created answers. `checked-in` connects it on the first poll and the drawer closes with the success toast, `pending` keeps the drawer waiting on the agent (the GCP agent setup command stays up), `error` fails the poll so the drawer reports it could not connect.',
|
||||
options: NEW_ACCOUNT_CHECK_INS,
|
||||
value: 'checked-in',
|
||||
}),
|
||||
accountUpdate: choiceControl<MutationState>('Account settings update', {
|
||||
group: MUTATIONS,
|
||||
description:
|
||||
@@ -221,19 +172,9 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
|
||||
mutationResolver(values.accountCreate, async (req, res, ctx) => {
|
||||
const body = (await req.json()) as CloudintegrationtypesPostableAccountDTO;
|
||||
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json(
|
||||
createdAccountResponse(
|
||||
values.provider,
|
||||
body.config?.gcp?.connectionMethod,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
mutationResolver(values.accountCreate, (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createdAccountResponse(values.provider))),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put(
|
||||
@@ -241,41 +182,21 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
mutationResolver(values.accountUpdate, (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
accountResponse(
|
||||
values.provider,
|
||||
String(req.params.id),
|
||||
values.gcpConnectionMethod,
|
||||
),
|
||||
),
|
||||
ctx.json(accountResponse(values.provider, String(req.params.id))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts/:id',
|
||||
accountResolver(
|
||||
values.provider,
|
||||
values.newAccountCheckIn,
|
||||
response.json((req) =>
|
||||
accountResponse(
|
||||
values.provider,
|
||||
String(req.params.id),
|
||||
values.gcpConnectionMethod,
|
||||
),
|
||||
),
|
||||
response.json((req) =>
|
||||
accountResponse(values.provider, String(req.params.id)),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/cloud_integrations/:cloudProvider/accounts',
|
||||
response.json(() =>
|
||||
accountsResponse(
|
||||
values.provider,
|
||||
values.accounts,
|
||||
values.gcpConnectionMethod,
|
||||
),
|
||||
),
|
||||
response.json(() => accountsResponse(values.provider, values.accounts)),
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
@@ -343,17 +264,6 @@ export const connectAzureAccount = async (
|
||||
);
|
||||
};
|
||||
|
||||
const addGcpProjectId = async (
|
||||
testId: string,
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(screen.getByTestId(testId));
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText('Type a project ID and press Enter'),
|
||||
`${projectId}{Enter}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const connectGcpAccount = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
@@ -368,40 +278,14 @@ export const connectGcpAccount = async (
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await userEvent.click(await screen.findByText('Mumbai (asia-south1)'));
|
||||
await addGcpProjectId('gcp-project-ids-select', 'project-a');
|
||||
await userEvent.type(
|
||||
document.querySelector<HTMLElement>('#gcp-project-ids-select') as HTMLElement,
|
||||
'project-a,',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('gcp-connect-account-btn'));
|
||||
};
|
||||
|
||||
export const openGcpAgentSetup = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openConnectFlow(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'gcp-connection-method-agent',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByTestId('gcp-generate-command-btn', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
export const generateGcpAgentCommand = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openGcpAgentSetup(canvasElement);
|
||||
await userEvent.type(
|
||||
screen.getByTestId('gcp-deployment-project-id-input'),
|
||||
'signoz-prod-4471',
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('gcp-deployment-region-select'));
|
||||
await userEvent.click(await screen.findByText('Council Bluffs (us-central1)'));
|
||||
await userEvent.click(screen.getByTestId('gcp-generate-command-btn'));
|
||||
};
|
||||
|
||||
export const openEditAccount = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const openEditAccount = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
/edit account/i,
|
||||
@@ -424,17 +308,6 @@ export const updateAwsAccount = async (
|
||||
);
|
||||
};
|
||||
|
||||
export const updateGcpProjects = async (
|
||||
canvasElement: HTMLElement,
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
await openEditAccount(canvasElement);
|
||||
await addGcpProjectId('gcp-edit-project-ids-select', projectId);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /update changes/i }),
|
||||
);
|
||||
};
|
||||
|
||||
export const updateTaggedAccount = async (
|
||||
canvasElement: HTMLElement,
|
||||
tag: string,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -13,13 +12,9 @@ import {
|
||||
connectAzureAccount,
|
||||
connectGcpAccount,
|
||||
expectToast,
|
||||
generateGcpAgentCommand,
|
||||
openConnectFlow,
|
||||
openEditAccount,
|
||||
openGcpAgentSetup,
|
||||
untilLoaded,
|
||||
updateAwsAccount,
|
||||
updateGcpProjects,
|
||||
updateTaggedAccount,
|
||||
} from './CloudIntegration.stories.mocks';
|
||||
|
||||
@@ -256,111 +251,6 @@ export const GcpRegionOpen: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The GCP connect drawer on its agent connection method: only the deployment
|
||||
* project, region and credentials, since the agent monitors the project it is
|
||||
* deployed in.
|
||||
*/
|
||||
export const GcpAgentSetup: Story = {
|
||||
args: { provider: 'gcp', accounts: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openGcpAgentSetup(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The setup command the agent connection generates, with the drawer waiting for
|
||||
* the agent to check in. Set New account check-in to
|
||||
* `checked-in` or `error` to see it connect or fail.
|
||||
*/
|
||||
export const GcpAgentCommand: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
await screen.findByTestId('gcp-agent-command', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The agent checks in on the first poll, so the drawer closes with a toast. */
|
||||
export const GcpAgentConnectedToast: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'checked-in' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
if (args.newAccountCheckIn === 'checked-in') {
|
||||
await expectToast(/gcp account connected successfully/i);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The connection poll fails, so the drawer reports it could not connect and
|
||||
* offers Check Again.
|
||||
*/
|
||||
export const GcpAgentConnectionError: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'error' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
await screen.findByTestId(
|
||||
'gcp-agent-connection-error',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Leaving the drawer while the agent has not connected yet, which asks first
|
||||
* because closing means starting the installation again.
|
||||
*/
|
||||
export const GcpAgentCloseConfirm: Story = {
|
||||
args: { provider: 'gcp', accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await generateGcpAgentCommand(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('gcp-cancel-btn', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByTestId('gcp-close-setup-btn', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings of a GCP account connected through the agent: read-only, since
|
||||
* the backend rejects updates to agent accounts.
|
||||
*/
|
||||
export const GcpAgentAccountSettings: Story = {
|
||||
args: {
|
||||
provider: 'gcp',
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openEditAccount(canvasElement);
|
||||
await screen.findByTestId(
|
||||
'gcp-agent-monitored-projects',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Disconnecting an agent account, which names what is left to clean up. */
|
||||
export const GcpAgentDisconnect: Story = {
|
||||
args: {
|
||||
provider: 'gcp',
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openEditAccount(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /disconnect/i }, untilLoaded),
|
||||
);
|
||||
await screen.findByText(
|
||||
/signoz-integration-bootstrap Infrastructure/i,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Saving the regions of a connected AWS account. Set Account settings update
|
||||
* to `error` for the failure toast.
|
||||
@@ -411,7 +301,7 @@ export const AzureAccountSettingsUpdateErrorToast: Story = {
|
||||
export const GcpAccountSettingsUpdatedToast: Story = {
|
||||
args: { provider: 'gcp' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await updateGcpProjects(canvasElement, 'extra-project');
|
||||
await updateTaggedAccount(canvasElement, 'extra-project');
|
||||
if (args.accountUpdate === 'success') {
|
||||
await expectToast(/account settings updated successfully/i);
|
||||
}
|
||||
@@ -422,7 +312,7 @@ export const GcpAccountSettingsUpdatedToast: Story = {
|
||||
export const GcpAccountSettingsUpdateErrorToast: Story = {
|
||||
args: { provider: 'gcp', accountUpdate: 'error' },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
await updateGcpProjects(canvasElement, 'extra-project');
|
||||
await updateTaggedAccount(canvasElement, 'extra-project');
|
||||
if (args.accountUpdate === 'error') {
|
||||
await expectToast(/failed to update account settings/i);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
CloudintegrationtypesAccountConfigDTO,
|
||||
CloudintegrationtypesAccountDTO,
|
||||
@@ -24,19 +23,6 @@ export type CloudProvider = (typeof CLOUD_PROVIDERS)[number];
|
||||
export const MUTATION_STATES = ['success', 'loading', 'error'] as const;
|
||||
export type MutationState = (typeof MUTATION_STATES)[number];
|
||||
|
||||
export const GCP_CONNECTION_METHODS = [
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.manual,
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent,
|
||||
] as const;
|
||||
|
||||
/** How the poll for the account the connect flow just created answers. */
|
||||
export const NEW_ACCOUNT_CHECK_INS = [
|
||||
'checked-in',
|
||||
'pending',
|
||||
'error',
|
||||
] as const;
|
||||
export type NewAccountCheckIn = (typeof NEW_ACCOUNT_CHECK_INS)[number];
|
||||
|
||||
interface CloudServiceSeed {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -280,7 +266,6 @@ const ACCOUNTS: Record<CloudProvider, readonly string[]> = {
|
||||
|
||||
const accountConfig = (
|
||||
provider: CloudProvider,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CloudintegrationtypesAccountConfigDTO => {
|
||||
if (provider === 'aws') {
|
||||
return { aws: { regions: ['us-east-1', 'eu-west-1', 'ap-south-1'] } };
|
||||
@@ -295,23 +280,8 @@ const accountConfig = (
|
||||
};
|
||||
}
|
||||
|
||||
// The backend only accepts the deployment project as an agent account's scope.
|
||||
if (
|
||||
gcpConnectionMethod === CloudintegrationtypesGCPConnectionMethodDTO.agent
|
||||
) {
|
||||
return {
|
||||
gcp: {
|
||||
connectionMethod: gcpConnectionMethod,
|
||||
deploymentRegion: 'us-central1',
|
||||
deploymentProjectId: 'signoz-prod-4471',
|
||||
projectIds: ['signoz-prod-4471'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
gcp: {
|
||||
connectionMethod: gcpConnectionMethod,
|
||||
deploymentRegion: 'us-central1',
|
||||
deploymentProjectId: 'signoz-prod-4471',
|
||||
projectIds: ['signoz-prod-4471', 'signoz-analytics-8820'],
|
||||
@@ -322,13 +292,12 @@ const accountConfig = (
|
||||
const account = (
|
||||
provider: CloudProvider,
|
||||
index: number,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CloudintegrationtypesAccountDTO => ({
|
||||
id: `${provider}-account-${index + 1}`,
|
||||
orgId: 'story-org',
|
||||
provider,
|
||||
providerAccountId: ACCOUNTS[provider][index],
|
||||
config: accountConfig(provider, gcpConnectionMethod),
|
||||
config: accountConfig(provider),
|
||||
agentReport: {
|
||||
timestampMillis: Date.now() - 45 * 1000,
|
||||
data: null,
|
||||
@@ -342,12 +311,11 @@ const account = (
|
||||
export const accountsResponse = (
|
||||
provider: CloudProvider,
|
||||
count: number,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): ListAccounts200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
accounts: Array.from({ length: count }, (_, index) =>
|
||||
account(provider, index, gcpConnectionMethod),
|
||||
account(provider, index),
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -355,52 +323,11 @@ export const accountsResponse = (
|
||||
export const accountResponse = (
|
||||
provider: CloudProvider,
|
||||
id: string,
|
||||
gcpConnectionMethod: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): GetAccount200 => {
|
||||
const parsed = Number.parseInt(id.replace(`${provider}-account-`, ''), 10);
|
||||
const index = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - 1);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: account(provider, index, gcpConnectionMethod),
|
||||
};
|
||||
};
|
||||
|
||||
/** The backend's command template, filled in for the story's project and region. */
|
||||
const GCP_AGENT_COMMAND = {
|
||||
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration gcp terraform \\
|
||||
--region 'us-central1' \\
|
||||
--gcp-project 'signoz-prod-4471' \\
|
||||
--integration-account-id 'gcp-account-new' \\
|
||||
--signoz-api-url 'https://story.us.signoz.cloud' \\
|
||||
--signoz-api-key 'story-api-key-8b7d02' \\
|
||||
--ingestion-url 'https://ingest.us.signoz.cloud:443' \\
|
||||
--ingestion-key 'story-ingestion-key-2f9c41' \\
|
||||
--agent-version 'v0.0.8'`,
|
||||
};
|
||||
|
||||
export const newAccountId = (provider: CloudProvider): string =>
|
||||
`${provider}-account-new`;
|
||||
|
||||
/**
|
||||
* The account the connect flow just created, as its connection poll sees it. Only
|
||||
* GCP's agent connection polls, so a GCP account here is an agent one.
|
||||
*/
|
||||
export const newAccountResponse = (
|
||||
provider: CloudProvider,
|
||||
isCheckedIn: boolean,
|
||||
): GetAccount200 => {
|
||||
const created = {
|
||||
...account(provider, 0, CloudintegrationtypesGCPConnectionMethodDTO.agent),
|
||||
id: newAccountId(provider),
|
||||
};
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: isCheckedIn
|
||||
? created
|
||||
: { ...created, providerAccountId: null, agentReport: null },
|
||||
};
|
||||
return { status: 'success', data: account(provider, index) };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -614,16 +541,14 @@ export const credentialsResponse = (): GetConnectionCredentials200 => ({
|
||||
|
||||
/**
|
||||
* The connection URL is what the setup flow opens in a new tab, which the story
|
||||
* reports through the navigation overlay rather than following. GCP returns its
|
||||
* setup command only for an agent connection; a manual one gets none.
|
||||
* reports through the navigation overlay rather than following.
|
||||
*/
|
||||
export const createdAccountResponse = (
|
||||
provider: CloudProvider,
|
||||
gcpConnectionMethod?: CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
): CreateAccount201 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: newAccountId(provider),
|
||||
id: `${provider}-account-new`,
|
||||
connectionArtifact: {
|
||||
aws: {
|
||||
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
|
||||
@@ -634,10 +559,6 @@ export const createdAccountResponse = (
|
||||
cloudPowerShellCommand:
|
||||
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
|
||||
},
|
||||
...(gcpConnectionMethod ===
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
|
||||
gcp: GCP_AGENT_COMMAND,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,14 +34,13 @@ import {
|
||||
PartialPanelTypes,
|
||||
} from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useNormalizedCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
|
||||
import { cloneDeep, get, isEqual, set } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
// ** Types
|
||||
@@ -114,7 +113,8 @@ export function QueryBuilderProvider({
|
||||
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const compositeQueryParam = useGetCompositeQueryParam();
|
||||
const normalizedCompositeQuery = useNormalizedCompositeQueryParam();
|
||||
const compositeQueryParam = normalizedCompositeQuery?.query ?? null;
|
||||
const { queryType: queryTypeParam, ...queryState } =
|
||||
compositeQueryParam || initialQueriesMap.metrics;
|
||||
|
||||
@@ -1115,20 +1115,22 @@ export function QueryBuilderProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const { isValid, validData } = replaceIncorrectObjectFields(
|
||||
compositeQueryParam,
|
||||
initialQueriesMap.metrics,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
redirectWithQueryBuilderData(validData);
|
||||
} else {
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
if (normalizedCompositeQuery?.changed) {
|
||||
const urlQuery = getUnstableCurrentSearchParams();
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQueryParam)),
|
||||
);
|
||||
safeNavigate(`${location.pathname}?${urlQuery}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
}
|
||||
}, [
|
||||
initQueryBuilderData,
|
||||
redirectWithQueryBuilderData,
|
||||
safeNavigate,
|
||||
normalizedCompositeQuery,
|
||||
compositeQueryParam,
|
||||
stagedQuery,
|
||||
location.pathname,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
renderHook,
|
||||
RenderHookResult,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
.createBrowserHistorySafeNavigateMock(),
|
||||
);
|
||||
|
||||
const builderlessUrlQuery = {
|
||||
queryType: 'clickhouse_sql',
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const clickhouseUrlQuery = {
|
||||
...builderlessUrlQuery,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
|
||||
const tracesWithOperator = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
};
|
||||
|
||||
const urlQuery = (): Query =>
|
||||
JSON.parse(
|
||||
decodeURIComponent(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery') ?? '',
|
||||
),
|
||||
);
|
||||
|
||||
const activeLogIdParam = (): string | null =>
|
||||
new URLSearchParams(window.location.search).get('activeLogId');
|
||||
|
||||
const renderOn = (
|
||||
route: string,
|
||||
compositeQuery: object,
|
||||
params: Record<string, string> = {},
|
||||
): RenderHookResult<QueryBuilderContextType, unknown> => {
|
||||
const search = new URLSearchParams(params);
|
||||
search.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
const url = `${route}?${search}`;
|
||||
window.history.replaceState(null, '', url);
|
||||
|
||||
return renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe('QueryBuilderProvider on a url compositeQuery without builder', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds supersetQuery with a builder so the first edit does not throw', () => {
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, builderlessUrlQuery);
|
||||
|
||||
expect(result.current.supersetQuery.builder).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetQueryData(0, {
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.supersetQuery.builder.queryData[0].limit).toBe(10);
|
||||
expect(result.current.currentQuery.builder.queryData[0].limit).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryBuilderProvider with page constraints', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds and stages the logs query and rewrites only compositeQuery', async () => {
|
||||
const replaceState = jest.spyOn(window.history, 'replaceState');
|
||||
const pushState = jest.spyOn(window.history, 'pushState');
|
||||
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, clickhouseUrlQuery, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(result.current.supersetQuery.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
expect(result.current.stagedQuery?.clickhouse_sql[0].query).toBe('');
|
||||
|
||||
expect(urlQuery().queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(urlQuery().builder.queryData[0].dataSource).toBe(DataSource.LOGS);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('');
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
expect(replaceState).toHaveBeenCalled();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a trace operator on the traces explorer', async () => {
|
||||
const { result } = renderOn(ROUTES.TRACES_EXPLORER, tracesWithOperator);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.builder.queryTraceOperator).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a valid url without source untouched and keeps activeLogId', async () => {
|
||||
const { source: _, ...item } = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData: [item] },
|
||||
};
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, query, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id),
|
||||
);
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery'),
|
||||
).toBe(JSON.stringify(query));
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
});
|
||||
|
||||
it('leaves a clickhouse query alone on a route without constraints', async () => {
|
||||
const { result } = renderOn('/unconstrained', clickhouseUrlQuery);
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.CLICKHOUSE),
|
||||
);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('SELECT 1');
|
||||
});
|
||||
});
|
||||
@@ -264,7 +264,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addGCPConnectionMethod struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type gcpConnectionMethodMigrationRow struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
Config string `bun:"config"`
|
||||
}
|
||||
|
||||
func NewAddGCPConnectionMethodFactory() factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_gcp_connection_method"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addGCPConnectionMethod{logger: ps.Logger}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var accounts []*gcpConnectionMethodMigrationRow
|
||||
if err := tx.NewSelect().
|
||||
Model(&accounts).
|
||||
Where("provider = ?", "gcp").
|
||||
Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
// Preserve config fields that are not known to this migration.
|
||||
var config map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(account.Config), &config); err != nil {
|
||||
migration.logger.WarnContext(ctx, "skipping GCP account with unreadable config", slog.String("account_id", account.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if config == nil {
|
||||
migration.logger.WarnContext(ctx, "skipping GCP account with null config", slog.String("account_id", account.ID))
|
||||
continue
|
||||
}
|
||||
if _, exists := config["connectionMethod"]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
config["connectionMethod"] = json.RawMessage(`"manual"`)
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().
|
||||
Model((*gcpConnectionMethodMigrationRow)(nil)).
|
||||
Set("config = ?", string(encoded)).
|
||||
Where("id = ?", account.ID).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addGCPConnectionMethod) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
|
||||
}
|
||||
account.Config.Azure = azureConfig
|
||||
case CloudProviderTypeGCP:
|
||||
gcpConfig := &GCPAccountConfig{ConnectionMethod: GCPConnectionMethodManual}
|
||||
gcpConfig := new(GCPAccountConfig)
|
||||
err := json.Unmarshal([]byte(storableAccount.Config), gcpConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -240,16 +240,25 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP config can not be nil for GCP provider")
|
||||
}
|
||||
|
||||
gcpConfig := &GCPAccountConfig{
|
||||
ConnectionMethod: config.GCP.ConnectionMethod,
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
if config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
}
|
||||
if err := gcpConfig.ErrIfInvalid(); err != nil {
|
||||
|
||||
if err := validateGCPRegion(config.GCP.DeploymentRegion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AccountConfig{GCP: gcpConfig}, nil
|
||||
|
||||
if len(config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
GCP: &GCPAccountConfig{
|
||||
DeploymentProjectID: config.GCP.DeploymentProjectID,
|
||||
ProjectIDs: config.GCP.ProjectIDs,
|
||||
DeploymentRegion: config.GCP.DeploymentRegion,
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
|
||||
}
|
||||
@@ -292,12 +301,12 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateGCPProjectIDs(config.Config.GCP.ProjectIDs); err != nil {
|
||||
return nil, err
|
||||
if len(config.Config.GCP.ProjectIDs) == 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one project id is required for GCP provider")
|
||||
}
|
||||
|
||||
if err := validateGCPDeploymentProjectID(config.Config.GCP.DeploymentProjectID); err != nil {
|
||||
return nil, err
|
||||
if config.Config.GCP.DeploymentProjectID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment project ID is required for GCP provider")
|
||||
}
|
||||
|
||||
return &AccountConfig{
|
||||
@@ -346,13 +355,6 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
|
||||
}
|
||||
|
||||
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
|
||||
if provider == CloudProviderTypeGCP {
|
||||
if account.Config.GCP.ConnectionMethod == GCPConnectionMethodAgent {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "GCP agent account configuration cannot be updated")
|
||||
}
|
||||
config.GCP.ConnectionMethod = account.Config.GCP.ConnectionMethod
|
||||
}
|
||||
|
||||
// deployment region can not be updated once set for Azure
|
||||
if provider == CloudProviderTypeAzure {
|
||||
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
|
||||
@@ -364,18 +366,6 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) ErrIfInvalidProviderAccountID(providerAccountID string) error {
|
||||
if account.Provider != CloudProviderTypeGCP || account.Config.GCP.ConnectionMethod != GCPConnectionMethodAgent {
|
||||
return nil
|
||||
}
|
||||
|
||||
if providerAccountID != account.Config.GCP.DeploymentProjectID {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "providerAccountId must match deploymentProjectId for GCP agent setup")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (account *Account) UpdateAgentReport(providerAccountID *string, agentReport *AgentReport) {
|
||||
account.ProviderAccountID = providerAccountID
|
||||
account.AgentReport = agentReport
|
||||
|
||||
@@ -1,69 +1,32 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
|
||||
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
|
||||
|
||||
foundryScriptURL = "https://signoz.io/foundry.sh"
|
||||
|
||||
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
|
||||
"shellQuote": func(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
},
|
||||
}).Parse(gcpCLITemplateStr()))
|
||||
)
|
||||
|
||||
type GCPConnectionMethod struct{ valuer.String }
|
||||
|
||||
func (GCPConnectionMethod) Enum() []any {
|
||||
return []any{GCPConnectionMethodManual, GCPConnectionMethodAgent}
|
||||
}
|
||||
|
||||
type GCPAccountConfig struct {
|
||||
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// Agent accounts store exactly the deployment project; manual accounts may monitor multiple projects.
|
||||
// Project ID where central pub/sub for logs exist
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Project ID where otel collector will be deployed
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPPostableAccountConfig struct {
|
||||
ConnectionMethod GCPConnectionMethod `json:"connectionMethod" required:"true"`
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// Agent setup requires exactly DeploymentProjectID; manual setup may select multiple projects.
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
}
|
||||
type GCPPostableAccountConfig = GCPAccountConfig
|
||||
|
||||
// UpdatableGCPAccountConfig contains settings for manual integrations only.
|
||||
type UpdatableGCPAccountConfig struct {
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
ProjectIDs []string `json:"projectIds" required:"true" nullable:"false"`
|
||||
// Project ID where central pub/sub for logs exist
|
||||
DeploymentProjectID string `json:"deploymentProjectId" required:"true"`
|
||||
// Compute service region where otel collector will be deployed
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
// List of project IDs to monitor
|
||||
ProjectIDs []string `json:"projectIds" required:"true"`
|
||||
}
|
||||
|
||||
type GCPConnectionArtifact struct {
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
}
|
||||
type GCPConnectionArtifact struct{}
|
||||
|
||||
type GCPIntegrationConfig struct {
|
||||
GCPAccountConfig
|
||||
TelemetryCollectionStrategy *GCPTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
|
||||
}
|
||||
type GCPIntegrationConfig struct{}
|
||||
|
||||
type GCPTelemetryCollectionStrategy struct {
|
||||
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"true" nullable:"true"`
|
||||
Logs *GCPLogsCollectionStrategy `json:"logs" required:"true" nullable:"true"`
|
||||
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
|
||||
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
|
||||
}
|
||||
|
||||
type GCPMetricsCollectionStrategy struct {
|
||||
@@ -87,120 +50,3 @@ type GCPServiceLogsConfig struct {
|
||||
type GCPServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled" required:"true"`
|
||||
}
|
||||
|
||||
func (config *GCPAccountConfig) ErrIfInvalid() error {
|
||||
if config.ConnectionMethod.IsZero() {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "connectionMethod is required for GCP provider")
|
||||
}
|
||||
if config.ConnectionMethod != GCPConnectionMethodManual && config.ConnectionMethod != GCPConnectionMethodAgent {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "unsupported GCP connectionMethod: %q", config.ConnectionMethod.StringValue())
|
||||
}
|
||||
if err := validateGCPDeploymentProjectID(config.DeploymentProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGCPRegion(config.DeploymentRegion); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateGCPProjectIDs(config.ProjectIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.ConnectionMethod == GCPConnectionMethodAgent && (len(config.ProjectIDs) != 1 || config.ProjectIDs[0] != config.DeploymentProjectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain only deploymentProjectId for GCP agent setup")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewGCPConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
|
||||
data := gcpTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
ProjectID: cfg.DeploymentProjectID,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
var command bytes.Buffer
|
||||
if err := gcpCLITemplate.Execute(&command, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GCPConnectionArtifact{
|
||||
CLICommand: command.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewGCPIntegrationConfig sorts and deduplicates the input slices in place and formats sink filters for joining with OR.
|
||||
func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters []string) *GCPIntegrationConfig {
|
||||
// Parentheses preserve each service filter's meaning when combined with OR.
|
||||
for i, filter := range sinkFilters {
|
||||
sinkFilters[i] = "(" + strings.TrimSpace(filter) + ")"
|
||||
}
|
||||
|
||||
// Keep the generated config stable and avoid collecting shared metrics more than once.
|
||||
slices.Sort(metricNames)
|
||||
metricNames = slices.Compact(metricNames)
|
||||
slices.Sort(sinkFilters)
|
||||
sinkFilters = slices.Compact(sinkFilters)
|
||||
|
||||
strategy := new(GCPTelemetryCollectionStrategy)
|
||||
if len(metricNames) > 0 {
|
||||
strategy.Metrics = &GCPMetricsCollectionStrategy{MetricNames: metricNames}
|
||||
}
|
||||
if len(sinkFilters) > 0 {
|
||||
strategy.Logs = &GCPLogsCollectionStrategy{SinkFilter: strings.Join(sinkFilters, " OR ")}
|
||||
}
|
||||
|
||||
return &GCPIntegrationConfig{
|
||||
GCPAccountConfig: *config,
|
||||
TelemetryCollectionStrategy: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
type gcpTemplateData struct {
|
||||
FoundryScriptURL string
|
||||
ProjectID string
|
||||
Region string
|
||||
IntegrationAccountID string
|
||||
AgentVersion string
|
||||
Credentials *Credentials
|
||||
}
|
||||
|
||||
func gcpCLITemplateStr() string {
|
||||
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration gcp terraform \
|
||||
--region {{.Region | shellQuote}} \
|
||||
--gcp-project {{.ProjectID | shellQuote}} \
|
||||
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
|
||||
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
|
||||
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
|
||||
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
|
||||
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
|
||||
--agent-version {{.AgentVersion | shellQuote}}`
|
||||
}
|
||||
|
||||
func validateGCPDeploymentProjectID(projectID string) error {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId is required for GCP provider")
|
||||
}
|
||||
if projectID != strings.TrimSpace(projectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "deploymentProjectId cannot contain leading or trailing whitespace")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGCPProjectIDs(projectIDs []string) error {
|
||||
if len(projectIDs) == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds must contain at least one project ID for GCP provider")
|
||||
}
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain an empty or whitespace-only project ID")
|
||||
}
|
||||
if projectID != strings.TrimSpace(projectID) {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidInput, "projectIds cannot contain leading or trailing whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
371
tests/e2e/drivers/dashboard-list.ts
generated
371
tests/e2e/drivers/dashboard-list.ts
generated
@@ -1,371 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { BASE_PATH } from '../helpers/base-path';
|
||||
import {
|
||||
buildDashboard,
|
||||
type PostableDashboard,
|
||||
type Tag,
|
||||
} from '../helpers/dashboard-spec';
|
||||
|
||||
import {
|
||||
DASHBOARDS_API,
|
||||
removeTrackedDashboards,
|
||||
trackDashboard,
|
||||
} from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The dashboards list page (`/dashboard`): the DSL search, sort, the New
|
||||
// dashboard modal and the per-row actions menu.
|
||||
|
||||
const LIST_PATH = '/dashboard';
|
||||
|
||||
/** The list route, with or without a query string. */
|
||||
export const LIST_URL = /\/dashboard(\?|$)/;
|
||||
|
||||
/** Workers and browser projects share one backend, so names carry a random suffix. */
|
||||
export function uniqueName(label: string): string {
|
||||
return `e2e list ${label} ${randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** The list filter DSL, which the search box and the `query` param carry. */
|
||||
export function nameContains(text: string): string {
|
||||
return `name CONTAINS '${text}'`;
|
||||
}
|
||||
|
||||
/** A panel-less dashboard: the list only needs its name and tags. */
|
||||
export function listDashboard(
|
||||
title: string,
|
||||
tags: Tag[] = [],
|
||||
): PostableDashboard {
|
||||
return buildDashboard({ title, sections: [], tags });
|
||||
}
|
||||
|
||||
export type SortColumn = 'name' | 'created' | 'updated';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
const SORT_OPTION: Record<SortColumn, string> = {
|
||||
name: 'sort-by-name',
|
||||
created: 'sort-by-last-created',
|
||||
updated: 'sort-by-last-updated',
|
||||
};
|
||||
|
||||
/** Row-menu items by the suffix of their `dashboard-action-<id>` test id. */
|
||||
export type RowAction =
|
||||
| 'view'
|
||||
| 'open-new-tab'
|
||||
| 'copy-link'
|
||||
| 'rename'
|
||||
| 'edit-tags'
|
||||
| 'duplicate'
|
||||
| 'lock'
|
||||
| 'delete';
|
||||
|
||||
export type NewDashboardTab = 'Blank' | 'From a template' | 'Import JSON';
|
||||
|
||||
/** A dashboard route, not the list or an editor route. */
|
||||
const DASHBOARD_ROUTE = /\/dashboard\/([^/?#]+)(?:[?#]|$)/;
|
||||
|
||||
export interface NewDashboardFields {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `key:value`, each committed with Enter. */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface DashboardList {
|
||||
/** Narrowed by the `query` param to one name, since the list is paged. */
|
||||
open(
|
||||
name?: string,
|
||||
sort?: { sort?: 'name' | 'created_at' | 'updated_at'; order?: SortOrder },
|
||||
): Promise<void>;
|
||||
newDashboardButton(): Locator;
|
||||
nameInput(): Locator;
|
||||
descriptionInput(): Locator;
|
||||
tagsInput(): Locator;
|
||||
tagChips(): Locator;
|
||||
submit(): Locator;
|
||||
/**
|
||||
* Fills and submits the Blank tab, then returns the id from the route it
|
||||
* lands on. The dashboard is deleted when the test ends.
|
||||
*/
|
||||
create(fields: NewDashboardFields): Promise<string>;
|
||||
/** Rows render titles in `dashboard-title-<index>`, not by id. */
|
||||
rowTitle(name: string): Locator;
|
||||
/** Opens the row's actions menu and confirms Delete. */
|
||||
delete(name: string): Promise<void>;
|
||||
|
||||
/** The page's own heading, named after the active view. */
|
||||
heading(): Locator;
|
||||
feedbackButton(): Locator;
|
||||
shareButton(): Locator;
|
||||
/** The search box's editable content (a CodeMirror editor). */
|
||||
search(): Locator;
|
||||
/** Shown only while the search box is empty. */
|
||||
searchPlaceholder(): Locator;
|
||||
/** Replaces the draft and runs it, as Run query does. */
|
||||
runSearch(query: string): Promise<void>;
|
||||
clearFilters(): Locator;
|
||||
noResults(): Locator;
|
||||
rows(): Locator;
|
||||
/** Visible row titles, top to bottom. */
|
||||
rowNames(): Promise<string[]>;
|
||||
/** Clicks the row's title and waits for its dashboard route. */
|
||||
openRow(name: string): Promise<void>;
|
||||
|
||||
sortButton(): Locator;
|
||||
sortBy(column: SortColumn): Promise<void>;
|
||||
sortOrder(order: SortOrder): Promise<void>;
|
||||
|
||||
openRowMenu(name: string): Promise<void>;
|
||||
rowMenu(): Locator;
|
||||
rowMenuItems(): Locator;
|
||||
rowAction(action: RowAction): Locator;
|
||||
/** Clicks the list heading, outside the open menu. */
|
||||
clickOutside(): Promise<void>;
|
||||
/** Resolves with the tab Open in New Tab opens. */
|
||||
openInNewTab(name: string): Promise<Page>;
|
||||
/** Copies the row's link and returns the clipboard text. */
|
||||
copyLink(name: string): Promise<string>;
|
||||
/** Opens the delete confirmation without answering it. */
|
||||
requestDelete(name: string): Promise<Locator>;
|
||||
/** The antd confirm carries no test id. */
|
||||
deleteConfirm(): Locator;
|
||||
cancelDelete(): Promise<void>;
|
||||
|
||||
newDashboardDialog(): Locator;
|
||||
openNewDashboard(tab?: NewDashboardTab): Promise<void>;
|
||||
newDashboardTabs(): Locator;
|
||||
jsonEditor(): Locator;
|
||||
uploadJsonButton(): Locator;
|
||||
importJsonSubmit(): Locator;
|
||||
/** Escape from the dialog title, since the JSON editor keeps Escape. */
|
||||
dismissWithEscape(): Promise<void>;
|
||||
closeDialog(): Promise<void>;
|
||||
|
||||
/** Sidebar entries are `div.nav-item`, not links. */
|
||||
sidebarDashboards(): Locator;
|
||||
back(): Promise<void>;
|
||||
}
|
||||
|
||||
function dashboardListDriver(page: Page): DashboardList {
|
||||
const rowTitle = (name: string): Locator =>
|
||||
page
|
||||
.locator('[data-testid^="dashboard-title-"]')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) });
|
||||
|
||||
const rowOf = (name: string): Locator =>
|
||||
rowTitle(name).locator(
|
||||
'xpath=ancestor::div[.//*[@data-testid="dashboard-action-icon"]][1]',
|
||||
);
|
||||
const search = (): Locator =>
|
||||
page.getByTestId('dashboards-list-search').locator('.cm-content');
|
||||
const rowMenu = (): Locator => page.getByRole('menu', { name: 'Action' });
|
||||
const newDashboardDialog = (): Locator =>
|
||||
page.getByRole('dialog', { name: 'New dashboard' });
|
||||
const deleteConfirm = (): Locator => page.locator('.ant-modal-confirm');
|
||||
|
||||
// The sort popover stays open after a pick, and its trigger toggles it.
|
||||
const pickSortOption = async (testId: string): Promise<void> => {
|
||||
const option = page.getByTestId(testId);
|
||||
if (!(await option.isVisible())) {
|
||||
await page.getByTestId('sort-by').click();
|
||||
}
|
||||
await option.click();
|
||||
};
|
||||
|
||||
const list: DashboardList = {
|
||||
async open(name, sort = {}) {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({
|
||||
query: name ? nameContains(name) : undefined,
|
||||
...sort,
|
||||
}).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
).toString();
|
||||
const search = params ? `?${params}` : '';
|
||||
await page.goto(`${LIST_PATH}${search}`);
|
||||
await expect(list.newDashboardButton()).toBeVisible();
|
||||
},
|
||||
newDashboardButton: () => page.getByTestId('new-dashboard-cta'),
|
||||
nameInput: () => page.getByTestId('create-dashboard-name'),
|
||||
descriptionInput: () => page.getByTestId('create-dashboard-description'),
|
||||
tagsInput: () => page.getByTestId('create-dashboard-tags'),
|
||||
tagChips: () => page.getByTestId('create-dashboard-tags-chip'),
|
||||
submit: () => page.getByTestId('create-dashboard-submit'),
|
||||
async create({ name, description, tags = [] }) {
|
||||
await list.newDashboardButton().click();
|
||||
await list.nameInput().fill(name);
|
||||
if (description !== undefined) {
|
||||
await list.descriptionInput().fill(description);
|
||||
}
|
||||
for (const tag of tags) {
|
||||
await list.tagsInput().fill(tag);
|
||||
await list.tagsInput().press('Enter');
|
||||
}
|
||||
await expect(list.tagChips()).toHaveCount(tags.length);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
// The app is served under the base path, and so is its API.
|
||||
new URL(r.url()).pathname === `${BASE_PATH}${DASHBOARDS_API}`,
|
||||
),
|
||||
list.submit().click(),
|
||||
]);
|
||||
// Tracked from the response, before the route settles, so a failed
|
||||
// navigation still cleans up.
|
||||
expect(response.ok(), `POST ${DASHBOARDS_API} failed`).toBe(true);
|
||||
const created = ((await response.json()) as { data: { id: string } }).data
|
||||
.id;
|
||||
trackDashboard(page, created);
|
||||
|
||||
await page.waitForURL(DASHBOARD_ROUTE);
|
||||
const id = DASHBOARD_ROUTE.exec(new URL(page.url()).pathname)?.[1];
|
||||
expect(id).toBe(created);
|
||||
return created;
|
||||
},
|
||||
rowTitle,
|
||||
async delete(name) {
|
||||
const confirm = await list.requestDelete(name);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'DELETE' &&
|
||||
r.url().includes(`${DASHBOARDS_API}/`),
|
||||
),
|
||||
confirm.getByRole('button', { name: 'Delete', exact: true }).click(),
|
||||
]);
|
||||
},
|
||||
|
||||
heading: () => page.getByRole('heading', { level: 1 }),
|
||||
feedbackButton: () => page.getByRole('button', { name: 'Feedback' }),
|
||||
shareButton: () => page.getByRole('button', { name: 'Share' }),
|
||||
search,
|
||||
searchPlaceholder: () => search().locator('.cm-placeholder'),
|
||||
async runSearch(query) {
|
||||
await search().click();
|
||||
await page.keyboard.press('ControlOrMeta+A');
|
||||
await page.keyboard.insertText(query);
|
||||
await expect(search()).toHaveText(query);
|
||||
await page.getByTestId('dashboards-list-search-submit').click();
|
||||
await page.waitForURL(
|
||||
(url) => (url.searchParams.get('query') ?? '') === query.trim(),
|
||||
);
|
||||
},
|
||||
clearFilters: () => page.getByTestId('dashboards-filter-clear'),
|
||||
noResults: () => page.getByText(/^No dashboards match/),
|
||||
rows: () => page.locator('[data-testid^="dashboard-title-"]'),
|
||||
rowNames: () => list.rows().allInnerTexts(),
|
||||
async openRow(name) {
|
||||
await rowTitle(name).click();
|
||||
await page.waitForURL(DASHBOARD_ROUTE);
|
||||
},
|
||||
|
||||
sortButton: () => page.getByTestId('sort-by'),
|
||||
async sortBy(column) {
|
||||
await pickSortOption(SORT_OPTION[column]);
|
||||
},
|
||||
async sortOrder(order) {
|
||||
await pickSortOption(`sort-order-${order}`);
|
||||
},
|
||||
|
||||
async openRowMenu(name) {
|
||||
await rowOf(name).getByTestId('dashboard-action-icon').click();
|
||||
await expect(rowMenu()).toBeVisible();
|
||||
},
|
||||
rowMenu,
|
||||
rowMenuItems: () => rowMenu().getByRole('menuitem'),
|
||||
rowAction: (action) => page.getByTestId(`dashboard-action-${action}`),
|
||||
async clickOutside() {
|
||||
// The open menu makes the rest of the page inert to locator clicks.
|
||||
const box = await list.heading().boundingBox();
|
||||
expect(box, 'list heading has no box').not.toBeNull();
|
||||
const { x, y, width, height } = box as NonNullable<typeof box>;
|
||||
await page.mouse.click(x + width / 2, y + height / 2);
|
||||
},
|
||||
async openInNewTab(name) {
|
||||
await list.openRowMenu(name);
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
list.rowAction('open-new-tab').click(),
|
||||
]);
|
||||
await tab.waitForLoadState();
|
||||
return tab;
|
||||
},
|
||||
async copyLink(name) {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await list.openRowMenu(name);
|
||||
await list.rowAction('copy-link').click();
|
||||
await expect(rowMenu()).toBeHidden();
|
||||
return page.evaluate(() =>
|
||||
(
|
||||
globalThis as unknown as {
|
||||
navigator: { clipboard: { readText(): Promise<string> } };
|
||||
}
|
||||
).navigator.clipboard.readText(),
|
||||
);
|
||||
},
|
||||
async requestDelete(name) {
|
||||
await list.openRowMenu(name);
|
||||
await list.rowAction('delete').click();
|
||||
await expect(deleteConfirm()).toContainText(name);
|
||||
return deleteConfirm();
|
||||
},
|
||||
deleteConfirm,
|
||||
async cancelDelete() {
|
||||
await deleteConfirm().getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(deleteConfirm()).toBeHidden();
|
||||
},
|
||||
|
||||
newDashboardDialog,
|
||||
async openNewDashboard(tab) {
|
||||
await list.newDashboardButton().click();
|
||||
await expect(newDashboardDialog()).toBeVisible();
|
||||
if (tab) {
|
||||
await newDashboardDialog().getByRole('tab', { name: tab }).click();
|
||||
}
|
||||
},
|
||||
newDashboardTabs: () => newDashboardDialog().getByRole('tab'),
|
||||
jsonEditor: () => newDashboardDialog().locator('.monaco-editor'),
|
||||
uploadJsonButton: () => page.getByTestId('upload-json-file'),
|
||||
importJsonSubmit: () => page.getByTestId('import-json-submit'),
|
||||
async dismissWithEscape() {
|
||||
await newDashboardDialog()
|
||||
.getByRole('heading', { name: 'New dashboard' })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
},
|
||||
async closeDialog() {
|
||||
await newDashboardDialog().getByRole('button', { name: 'Close' }).click();
|
||||
},
|
||||
|
||||
sidebarDashboards: () =>
|
||||
page.locator('.nav-item').filter({ hasText: /^Dashboards$/ }),
|
||||
async back() {
|
||||
await page.goBack();
|
||||
},
|
||||
};
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const withDashboardList = driver(
|
||||
'dashboardList',
|
||||
({ page }) => dashboardListDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
export const test = withDashboardList(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
355
tests/e2e/drivers/dashboards.ts
generated
355
tests/e2e/drivers/dashboards.ts
generated
@@ -1,355 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Route,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken } from '../helpers/common';
|
||||
import type { PanelKind, PostableDashboard } from '../helpers/dashboard-spec';
|
||||
import {
|
||||
mockQueryRange,
|
||||
mockQueryRangeError,
|
||||
mockQueryRangeSequence,
|
||||
type MockOptions,
|
||||
type QueryRangeBody,
|
||||
unmockQueryRange,
|
||||
} from '../helpers/query-range-mock';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
|
||||
// `POST /api/v2/dashboards` takes the full perses spec in one call.
|
||||
|
||||
export const DASHBOARDS_API = '/api/v2/dashboards';
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
export const PATH = {
|
||||
dashboard: (id: string): string => `/dashboard/${id}`,
|
||||
/** `panelId` is 'new' for creation. */
|
||||
editor: (id: string, panelId: string): string =>
|
||||
`/dashboard/${id}/panel/${panelId}`,
|
||||
/** Matches the dashboard route itself, with or without a query string. */
|
||||
onDashboard: (id: string): RegExp => new RegExp(`/dashboard/${id}(\\?|$)`),
|
||||
/** Matches the dashboard route once the View modal has written its params. */
|
||||
onDashboardWithParams: (id: string): RegExp =>
|
||||
new RegExp(`/dashboard/${id}\\?`),
|
||||
newPanel: /\/panel\/new\?/,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `panel-editor-v2` is not unique: the ResizablePanelGroup derives the same
|
||||
* testid from its `id`, which is also its localStorage layout key.
|
||||
*/
|
||||
export const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
|
||||
|
||||
export interface GettableDashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
locked: boolean;
|
||||
tags: { key: string; value: string }[];
|
||||
spec: PostableDashboard['spec'];
|
||||
}
|
||||
|
||||
export interface Dashboards {
|
||||
/** Created for this test and deleted when it ends. */
|
||||
seed(dashboard: PostableDashboard): Promise<string>;
|
||||
seedAndOpen(dashboard: PostableDashboard): Promise<string>;
|
||||
seedAndEdit(dashboard: PostableDashboard, panelId: string): Promise<string>;
|
||||
/** Deletes a dashboard the UI created when the test ends. */
|
||||
track(id: string): void;
|
||||
/** The open dashboard's heading. */
|
||||
title(): Locator;
|
||||
/** Waits for the grid, not just the shell. */
|
||||
open(id: string): Promise<void>;
|
||||
/** For a dashboard with no panels: waits for the empty state instead. */
|
||||
openEmpty(id: string): Promise<void>;
|
||||
openEditor(id: string, panelId: string): Promise<void>;
|
||||
/** `kind` is required: without it the route bounces to the dashboard. */
|
||||
openNewPanel(id: string, kind: PanelKind, layoutIndex?: number): Promise<void>;
|
||||
/** Navigates without waiting for anything, for routes expected to redirect. */
|
||||
visit(path: string): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
get(id: string): Promise<GettableDashboard>;
|
||||
setLocked(id: string, locked: boolean): Promise<void>;
|
||||
/** A panel's grid geometry as persisted, not as rendered. */
|
||||
gridItem(id: string, panelId: string): Promise<GridGeometry>;
|
||||
/** A query param of the current URL, where the time window lives. */
|
||||
param(name: string): string | null;
|
||||
emptyAddPanel(): Locator;
|
||||
headerAddPanel(): Locator;
|
||||
zoomOut(): Locator;
|
||||
variable(name: string): Locator;
|
||||
confirmDelete(): Locator;
|
||||
/** Free text anywhere on the page, e.g. a confirm dialog's title. */
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
export interface GridGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface QueryRangeMock {
|
||||
/** Fulfils every call with one body. Install before navigating. */
|
||||
respond(body: QueryRangeBody, options?: MockOptions): Promise<void>;
|
||||
fail(
|
||||
message?: string,
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void>;
|
||||
/** One body per call, repeating the last. */
|
||||
sequence(bodies: QueryRangeBody[]): Promise<void>;
|
||||
/** Holds every call until `release` is called, then serves `body`. */
|
||||
hold(body: QueryRangeBody): Promise<{ release: () => void }>;
|
||||
/** Drops interception so later calls reach the backend. */
|
||||
reset(): Promise<void>;
|
||||
/** Resolves with the next query_range request. */
|
||||
nextRequest(): Promise<Request>;
|
||||
/** The paging of the first query in the next query_range request. */
|
||||
nextPaging(): Promise<{ offset?: number; limit?: number }>;
|
||||
/** Counts query_range requests from now on. */
|
||||
countRequests(): { readonly count: number };
|
||||
}
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 400 is almost always the strict decoder rejecting a stray key; the body
|
||||
* names the field, so it is surfaced verbatim.
|
||||
*/
|
||||
async function createViaApi(
|
||||
page: Page,
|
||||
dashboard: PostableDashboard,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(DASHBOARDS_API, {
|
||||
data: dashboard,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${DASHBOARDS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboard }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableDashboard> {
|
||||
const res = await page.request.get(`${DASHBOARDS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${DASHBOARDS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboard }).data;
|
||||
}
|
||||
|
||||
/** Same path, no body: PUT locks, DELETE unlocks. */
|
||||
async function setLockedViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
locked: boolean,
|
||||
): Promise<void> {
|
||||
const url = `${DASHBOARDS_API}/${id}/lock`;
|
||||
const headers = await bearer(page);
|
||||
const res = locked
|
||||
? await page.request.put(url, { headers })
|
||||
: await page.request.delete(url, { headers });
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`${locked ? 'PUT' : 'DELETE'} ${url} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a dashboard for deletion when the test owning `page` ends. */
|
||||
export function trackDashboard(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not asserted: a test that deleted its own dashboard, or left a closed page
|
||||
* behind, must not fail on cleanup.
|
||||
*/
|
||||
export async function removeTrackedDashboards(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = await bearer(page);
|
||||
for (const id of ids) {
|
||||
await page.request
|
||||
.delete(`${DASHBOARDS_API}/${id}`, { headers })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
/* the dashboards outlive the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardsDriver(page: Page): Dashboards {
|
||||
const editorRoot = (): Locator => page.locator(EDITOR_ROOT);
|
||||
|
||||
const dashboards: Dashboards = {
|
||||
async seed(dashboard) {
|
||||
const id = await createViaApi(page, dashboard);
|
||||
trackDashboard(page, id);
|
||||
return id;
|
||||
},
|
||||
track: (id) => trackDashboard(page, id),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
async seedAndOpen(dashboard) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.open(id);
|
||||
return id;
|
||||
},
|
||||
async seedAndEdit(dashboard, panelId) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.openEditor(id, panelId);
|
||||
return id;
|
||||
},
|
||||
async open(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await page
|
||||
.locator('[data-panel-root]')
|
||||
.first()
|
||||
.waitFor({ state: 'attached' });
|
||||
},
|
||||
async openEmpty(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await expect(dashboards.emptyAddPanel()).toBeVisible();
|
||||
},
|
||||
async openEditor(id, panelId) {
|
||||
await page.goto(PATH.editor(id, panelId));
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async openNewPanel(id, kind, layoutIndex = 0) {
|
||||
const search = new URLSearchParams({
|
||||
panelKind: kind,
|
||||
layoutIndex: String(layoutIndex),
|
||||
});
|
||||
await page.goto(`${PATH.editor(id, 'new')}?${search.toString()}`);
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async visit(path) {
|
||||
await page.goto(path);
|
||||
},
|
||||
async reload() {
|
||||
await page.reload();
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
setLocked: (id, locked) => setLockedViaApi(page, id, locked),
|
||||
async gridItem(id, panelId) {
|
||||
const { spec } = await getViaApi(page, id);
|
||||
for (const layout of spec.layouts) {
|
||||
const item = layout.spec.items.find(
|
||||
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
|
||||
);
|
||||
if (item) {
|
||||
return { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||
}
|
||||
}
|
||||
throw new Error(`no grid item for panel ${panelId}`);
|
||||
},
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
emptyAddPanel: () => page.getByTestId('add-panel'),
|
||||
headerAddPanel: () => page.getByTestId('add-panel-header'),
|
||||
zoomOut: () => page.getByTestId('zoom-out-btn'),
|
||||
variable: (name) => page.getByTestId(`variable-${name}`),
|
||||
confirmDelete: () => page.getByTestId('confirm-delete'),
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return dashboards;
|
||||
}
|
||||
|
||||
function queryRangeDriver(page: Page): QueryRangeMock {
|
||||
const isQueryRange = (url: string): boolean => url.includes('/query_range');
|
||||
|
||||
return {
|
||||
respond: (body, options) => mockQueryRange(page, body, options),
|
||||
fail: (message, options) => mockQueryRangeError(page, message, options),
|
||||
sequence: (bodies) => mockQueryRangeSequence(page, bodies),
|
||||
async hold(body) {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
return { release: () => release() };
|
||||
},
|
||||
reset: () => unmockQueryRange(page),
|
||||
nextRequest: () => page.waitForRequest((r) => isQueryRange(r.url())),
|
||||
async nextPaging() {
|
||||
const request = await page.waitForRequest((r) => isQueryRange(r.url()));
|
||||
const body = request.postDataJSON() as {
|
||||
compositeQuery?: {
|
||||
queries?: { spec?: { offset?: number; limit?: number } }[];
|
||||
};
|
||||
} | null;
|
||||
const spec = body?.compositeQuery?.queries?.[0]?.spec;
|
||||
return { offset: spec?.offset, limit: spec?.limit };
|
||||
},
|
||||
countRequests() {
|
||||
const counter = { count: 0 };
|
||||
page.on('request', (request) => {
|
||||
if (isQueryRange(request.url())) {
|
||||
counter.count += 1;
|
||||
}
|
||||
});
|
||||
return counter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const withDashboards = driver(
|
||||
'dashboards',
|
||||
({ page }) => dashboardsDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
const withQueryRange = driver('queryRange', ({ page }) =>
|
||||
queryRangeDriver(page),
|
||||
);
|
||||
|
||||
export const test = withQueryRange(withDashboards(kit));
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from '../helpers/dashboard-spec';
|
||||
export {
|
||||
flat,
|
||||
type HeatmapPoint,
|
||||
heatmapPoints,
|
||||
type HeatmapSeriesSpec,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
ramp,
|
||||
type SeriesSpec,
|
||||
timestamps,
|
||||
type TimeSeriesPoint,
|
||||
} from '../helpers/query-range-mock';
|
||||
export * from '../testdata/dashboards/panels';
|
||||
13
tests/e2e/drivers/kit.ts
generated
13
tests/e2e/drivers/kit.ts
generated
@@ -27,15 +27,10 @@ export type WorkerLayer<Adds> = <T extends object, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T, W & Adds>;
|
||||
|
||||
/**
|
||||
* A `withX` layer binding `build` to the authenticated page. `dispose` runs
|
||||
* after the test while the page is still open, so it can clean up over the
|
||||
* same session.
|
||||
*/
|
||||
/** A `withX` layer binding `build` to the authenticated page. */
|
||||
export function driver<Name extends string, T>(
|
||||
name: Name,
|
||||
build: (ctx: DriverContext) => T,
|
||||
dispose?: (value: T, ctx: DriverContext) => Promise<void>,
|
||||
): Layer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
// Playwright reads fixture dependencies from the source text, so
|
||||
@@ -44,11 +39,7 @@ export function driver<Name extends string, T>(
|
||||
{ authedPage }: { authedPage: Page },
|
||||
use: (value: T) => Promise<void>,
|
||||
) => {
|
||||
const value = build({ page: authedPage });
|
||||
await use(value);
|
||||
if (dispose) {
|
||||
await dispose(value, { page: authedPage });
|
||||
}
|
||||
await use(build({ page: authedPage }));
|
||||
},
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
|
||||
278
tests/e2e/drivers/panel-config.ts
generated
278
tests/e2e/drivers/panel-config.ts
generated
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type {
|
||||
ContextLink,
|
||||
PanelPluginSpec,
|
||||
Threshold,
|
||||
} from '../helpers/dashboard-spec';
|
||||
import { SINGLE_PANEL_ID } from '../testdata/dashboards/panels';
|
||||
|
||||
import { getViaApi } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The panel editor's per-kind config editors: thresholds, context links,
|
||||
// per-column units and the List columns editor.
|
||||
|
||||
/**
|
||||
* Variant follows panel kind, not a user control: label → TimeSeries/Bar,
|
||||
* comparison → Number, table → Table. Each has its own add button and row
|
||||
* testid prefix.
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
const THRESHOLD_ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
label: 'panel-editor-v2-add-threshold',
|
||||
comparison: 'panel-editor-v2-add-comparison-threshold',
|
||||
table: 'panel-editor-v2-add-table-threshold',
|
||||
};
|
||||
|
||||
const THRESHOLD_ROW_PREFIX: Record<ThresholdVariant, string> = {
|
||||
label: 'threshold',
|
||||
comparison: 'comparison-threshold',
|
||||
table: 'table-threshold',
|
||||
};
|
||||
|
||||
export type ThresholdOperator =
|
||||
| 'above'
|
||||
| 'above_or_equal'
|
||||
| 'below'
|
||||
| 'below_or_equal'
|
||||
| 'equal'
|
||||
| 'not_equal';
|
||||
|
||||
export type ThresholdFormat = 'background' | 'text';
|
||||
|
||||
export interface ThresholdRow {
|
||||
value(): Locator;
|
||||
/** Label variant only. */
|
||||
label(): Locator;
|
||||
edit(): Locator;
|
||||
save(): Locator;
|
||||
discard(): Locator;
|
||||
remove(): Locator;
|
||||
/** Operator and format are radio tiles keyed by the persisted value. */
|
||||
setOperator(operator: ThresholdOperator): Promise<void>;
|
||||
setFormat(format: ThresholdFormat): Promise<void>;
|
||||
/** Table variant only: the column keys come from the live result. */
|
||||
pickFirstColumn(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Thresholds {
|
||||
addButton(variant: ThresholdVariant): Locator;
|
||||
/** The section header's quick-add, which also expands a collapsed section. */
|
||||
headerAdd(): Locator;
|
||||
row(variant: ThresholdVariant, index: number): ThresholdRow;
|
||||
}
|
||||
|
||||
export interface ContextLinks {
|
||||
addButton(): Locator;
|
||||
headerAdd(): Locator;
|
||||
dialog(): Locator;
|
||||
label(): Locator;
|
||||
url(): Locator;
|
||||
save(): Locator;
|
||||
cancel(): Locator;
|
||||
addParam(): Locator;
|
||||
paramKey(index: number): Locator;
|
||||
paramValue(index: number): Locator;
|
||||
item(index: number): Locator;
|
||||
edit(index: number): Locator;
|
||||
remove(index: number): Locator;
|
||||
/**
|
||||
* Closes the URL field's suggestion popover, which otherwise keeps the
|
||||
* dialog reflowing so buttons never settle. Escape would dismiss the whole
|
||||
* dialog.
|
||||
*/
|
||||
blurFields(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ListColumns {
|
||||
editor(): Locator;
|
||||
chip(name: string): Locator;
|
||||
remove(name: string): Locator;
|
||||
addButton(): Locator;
|
||||
search(): Locator;
|
||||
/** The explicit "Add …" entry, so a typo can't be committed by blurring. */
|
||||
addCustom(): Locator;
|
||||
/** Opens the dropdown and types `term`. */
|
||||
open(term: string): Promise<void>;
|
||||
/**
|
||||
* Clicks the first suggestion once the results for `term` have landed;
|
||||
* returns its field name.
|
||||
*/
|
||||
pickFirstSuggestion(term: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface TableCells {
|
||||
/** The last cell of the first row, where the aggregated value lands. */
|
||||
valueCell(): Locator;
|
||||
/** Inline background, set only while a background threshold matches. */
|
||||
background(cell: Locator): Promise<string>;
|
||||
/** Spans the text format recolours. */
|
||||
coloredText(cell: Locator): Locator;
|
||||
}
|
||||
|
||||
export interface SavedPanel {
|
||||
plugin: PanelPluginSpec;
|
||||
links: ContextLink[];
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
thresholds: Thresholds;
|
||||
contextLinks: ContextLinks;
|
||||
listColumns: ListColumns;
|
||||
columnUnits: {
|
||||
any(): Locator;
|
||||
/** The first per-column unit select, with the column key it targets. */
|
||||
first(): Promise<{ testId: string; key: string }>;
|
||||
};
|
||||
/** The plugin spec and links as persisted, straight from the API. */
|
||||
saved(dashboardId: string, panelId?: string): Promise<SavedPanel>;
|
||||
savedThresholds(dashboardId: string, panelId?: string): Promise<Threshold[]>;
|
||||
numberValue(): Locator;
|
||||
/** Inline colour on the rendered Number value, set only while a threshold matches. */
|
||||
numberValueColor(): Promise<string>;
|
||||
table: TableCells;
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
const COLUMN_UNIT_PREFIX = 'panel-editor-v2-column-unit-';
|
||||
const SUGGESTION_PREFIX = 'list-columns-suggestion-';
|
||||
|
||||
function thresholdRow(
|
||||
page: Page,
|
||||
variant: ThresholdVariant,
|
||||
index: number,
|
||||
): ThresholdRow {
|
||||
const prefix = THRESHOLD_ROW_PREFIX[variant];
|
||||
const byTestId = (part: string): Locator =>
|
||||
page.getByTestId(`${prefix}-${part}-${index}`);
|
||||
return {
|
||||
value: () => byTestId('value'),
|
||||
label: () => byTestId('label'),
|
||||
edit: () => byTestId('edit'),
|
||||
save: () => byTestId('save'),
|
||||
discard: () => byTestId('discard'),
|
||||
remove: () => byTestId('remove'),
|
||||
async setOperator(operator) {
|
||||
await page.getByTestId(`${prefix}-operator-${index}-${operator}`).click();
|
||||
},
|
||||
async setFormat(format) {
|
||||
await page.getByTestId(`${prefix}-format-${index}-${format}`).click();
|
||||
},
|
||||
async pickFirstColumn() {
|
||||
await byTestId('column').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.locator('.ant-select-item-option')
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function panelConfigDriver(page: Page): PanelConfig {
|
||||
const numberValue = (): Locator => page.getByTestId('number-panel-value');
|
||||
const contextLinkLabel = (): Locator => page.getByTestId('context-link-label');
|
||||
const columnUnits = (): Locator =>
|
||||
page.locator(`[data-testid^="${COLUMN_UNIT_PREFIX}"]`);
|
||||
|
||||
const config: PanelConfig = {
|
||||
thresholds: {
|
||||
addButton: (variant) => page.getByTestId(THRESHOLD_ADD_TESTID[variant]),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-threshold-header'),
|
||||
row: (variant, index) => thresholdRow(page, variant, index),
|
||||
},
|
||||
contextLinks: {
|
||||
addButton: () => page.getByTestId('panel-editor-v2-add-link'),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-link-header'),
|
||||
dialog: () => page.getByTestId('context-link-dialog'),
|
||||
label: contextLinkLabel,
|
||||
url: () => page.getByTestId('context-link-url'),
|
||||
save: () => page.getByTestId('context-link-save'),
|
||||
cancel: () => page.getByTestId('context-link-cancel'),
|
||||
addParam: () => page.getByTestId('context-link-add-param'),
|
||||
paramKey: (index) => page.getByTestId(`context-link-param-key-${index}`),
|
||||
paramValue: (index) => page.getByTestId(`context-link-param-value-${index}`),
|
||||
item: (index) => page.getByTestId(`context-link-item-${index}`),
|
||||
edit: (index) => page.getByTestId(`context-link-edit-${index}`),
|
||||
remove: (index) => page.getByTestId(`context-link-remove-${index}`),
|
||||
async blurFields() {
|
||||
await contextLinkLabel().focus();
|
||||
},
|
||||
},
|
||||
listColumns: {
|
||||
editor: () => page.getByTestId('list-columns-editor'),
|
||||
chip: (name) => page.getByTestId(`list-column-chip-${name}`),
|
||||
remove: (name) => page.getByTestId(`list-column-remove-${name}`),
|
||||
addButton: () => page.getByTestId('list-columns-add'),
|
||||
search: () => page.getByTestId('list-columns-search'),
|
||||
addCustom: () => page.getByTestId('list-columns-add-custom'),
|
||||
async open(term) {
|
||||
await page.getByTestId('list-columns-add').click();
|
||||
await page.getByTestId('list-columns-search').fill(term);
|
||||
},
|
||||
async pickFirstSuggestion(term) {
|
||||
const first = page.locator(`[data-testid^="${SUGGESTION_PREFIX}"]`).first();
|
||||
const firstName = async (): Promise<string> =>
|
||||
((await first.getAttribute('data-testid')) ?? '').replace(
|
||||
SUGGESTION_PREFIX,
|
||||
'',
|
||||
);
|
||||
// Search is debounced, so the previous results stay up until this
|
||||
// term's land; and the list repaints as they do, so the row is
|
||||
// clicked by its own testid rather than by position.
|
||||
await expect(page.getByText('Loading…')).toHaveCount(0);
|
||||
await expect.poll(firstName).toContain(term);
|
||||
const name = await firstName();
|
||||
await page.getByTestId(`${SUGGESTION_PREFIX}${name}`).click();
|
||||
return name;
|
||||
},
|
||||
},
|
||||
columnUnits: {
|
||||
any: () => columnUnits().first(),
|
||||
async first() {
|
||||
const testId =
|
||||
(await columnUnits().first().getAttribute('data-testid')) ?? '';
|
||||
return { testId, key: testId.replace(COLUMN_UNIT_PREFIX, '') };
|
||||
},
|
||||
},
|
||||
async saved(dashboardId, panelId = SINGLE_PANEL_ID) {
|
||||
const { spec } = await getViaApi(page, dashboardId);
|
||||
const panel = spec.panels[panelId].spec;
|
||||
return { plugin: panel.plugin.spec, links: panel.links ?? [] };
|
||||
},
|
||||
async savedThresholds(dashboardId, panelId) {
|
||||
return (await config.saved(dashboardId, panelId)).plugin.thresholds ?? [];
|
||||
},
|
||||
numberValue,
|
||||
numberValueColor: () =>
|
||||
numberValue().evaluate((node) => (node as HTMLElement).style.color),
|
||||
table: {
|
||||
valueCell: () =>
|
||||
page
|
||||
.getByTestId('table-panel-renderer')
|
||||
.locator('tbody tr.ant-table-row')
|
||||
.first()
|
||||
.locator('td')
|
||||
.last(),
|
||||
background: (cell) =>
|
||||
cell.evaluate((node) => (node as HTMLElement).style.backgroundColor),
|
||||
coloredText: (cell) => cell.locator('span[style*="color"]'),
|
||||
},
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const withPanelConfig = driver('config', ({ page }) => panelConfigDriver(page));
|
||||
|
||||
export const test = withPanelConfig(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
296
tests/e2e/drivers/panel-editor.ts
generated
296
tests/e2e/drivers/panel-editor.ts
generated
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-spec';
|
||||
|
||||
import { EDITOR_ROOT } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { newPanelDriver } from './panel-editor/picker';
|
||||
import { type QueryPane, queryPaneAt } from './panel-editor/query';
|
||||
import { type EditorSelects, selectsAt } from './panel-editor/select';
|
||||
import { type TextPane, textPaneAt } from './panel-editor/text';
|
||||
import {
|
||||
type TypeSwitcher,
|
||||
typeSwitcherAt,
|
||||
} from './panel-editor/type-switcher';
|
||||
import {
|
||||
type Chart,
|
||||
chartAt,
|
||||
type Markdown,
|
||||
markdownAt,
|
||||
RENDERER_TESTID,
|
||||
test as panelsTest,
|
||||
} from './panels';
|
||||
|
||||
// The panel editor; config sections only mount their editors while open.
|
||||
// Each pane lives in its own part under ./panel-editor.
|
||||
|
||||
export const EditorText = {
|
||||
title: 'Configure panel',
|
||||
unsavedBadge: 'Unsaved Changes',
|
||||
save: 'Save changes',
|
||||
switchToView: 'Switch to View Mode',
|
||||
discardTitle: 'Discard changes?',
|
||||
discardBody: 'Your unsaved edits to this panel will be lost.',
|
||||
savedToast: 'Panel saved',
|
||||
lockedReason: 'This dashboard is locked',
|
||||
runQuery: 'Run Query',
|
||||
} as const;
|
||||
|
||||
export const QueryTab = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse Query',
|
||||
promql: 'PromQL',
|
||||
ai: 'AI Query Builder',
|
||||
} as const;
|
||||
|
||||
/** SettingsSection titles as rendered. */
|
||||
export const Section = {
|
||||
visualization: 'Visualization',
|
||||
formatting: 'Formatting & Units',
|
||||
axes: 'Axes',
|
||||
legend: 'Legend',
|
||||
chartAppearance: 'Chart Appearance',
|
||||
buckets: 'Buckets',
|
||||
thresholds: 'Thresholds',
|
||||
contextLinks: 'Context Links',
|
||||
/** Text only. */
|
||||
panelAppearance: 'Panel appearance',
|
||||
} as const;
|
||||
|
||||
/** Config controls, each testid'd as `panel-editor-v2-<name>`. */
|
||||
export type EditorControl =
|
||||
| 'soft-min'
|
||||
| 'soft-max'
|
||||
| 'y-scale'
|
||||
| 'log-scale'
|
||||
| 'unit'
|
||||
| 'decimals'
|
||||
| 'time-preference'
|
||||
| 'stacked-bar-chart'
|
||||
| 'stack-mode'
|
||||
| 'fill-spans'
|
||||
| 'fill-mode'
|
||||
| 'fill-opacity'
|
||||
| 'span-gaps'
|
||||
| 'span-gaps-value'
|
||||
| 'show-points'
|
||||
| 'line-style'
|
||||
| 'line-interpolation'
|
||||
| 'legend-position'
|
||||
| 'legend-colors'
|
||||
| 'legend-search'
|
||||
| 'bucket-count'
|
||||
| 'bucket-width'
|
||||
| 'merge-queries'
|
||||
| 'heatmap-color-mode'
|
||||
| 'heatmap-palette'
|
||||
| 'heatmap-color-scale'
|
||||
| 'heatmap-color-steps'
|
||||
| 'heatmap-min-count'
|
||||
| 'heatmap-max-count'
|
||||
| 'heatmap-fill'
|
||||
| 'heatmap-preview'
|
||||
| 'type-revert';
|
||||
|
||||
export interface PatchOperation {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface PanelEditor extends EditorSelects, TypeSwitcher {
|
||||
root(): Locator;
|
||||
title(): Locator;
|
||||
description(): Locator;
|
||||
saveButton(): Locator;
|
||||
closeButton(): Locator;
|
||||
unsavedBadge(): Locator;
|
||||
switchToView(): Locator;
|
||||
/** Save is not gated on dirty state; fails the test on a rejected PATCH. */
|
||||
save(): Promise<void>;
|
||||
/** Clicks Save and returns whether the PATCH was accepted, for rejection paths. */
|
||||
trySave(): Promise<{ ok: boolean; body: string }>;
|
||||
/**
|
||||
* A dirty panel raises the discard dialog; `expectDirty` answers it, with
|
||||
* `keepEditing` choosing Cancel over Discard.
|
||||
*/
|
||||
close(options?: {
|
||||
expectDirty?: boolean;
|
||||
keepEditing?: boolean;
|
||||
}): Promise<void>;
|
||||
discardModal(): Locator;
|
||||
/** Records the RFC-6902 ops each save sends. */
|
||||
capturePatches(): PatchOperation[][];
|
||||
section: {
|
||||
toggle(title: string): Locator;
|
||||
/** Idempotent: a blind click on an open section collapses it. */
|
||||
expand(title: string): Promise<void>;
|
||||
collapse(title: string): Promise<void>;
|
||||
};
|
||||
control(name: EditorControl): Locator;
|
||||
/**
|
||||
* A ConfigTiles choice. The radio input is visually hidden, so the label
|
||||
* carrying `<group testid>-<value>` is the click target.
|
||||
*/
|
||||
tile(name: EditorControl, value: string): Locator;
|
||||
setTile(name: EditorControl, value: string): Promise<void>;
|
||||
/** The tile's radio input, for `toBeChecked`. */
|
||||
tileInput(name: EditorControl, value: string): Locator;
|
||||
/**
|
||||
* Whether a refresh or tab close would be held by the editor's
|
||||
* `beforeunload` prompt, checked without leaving the page.
|
||||
*/
|
||||
blocksUnload(): Promise<boolean>;
|
||||
/**
|
||||
* Drives a slider control from the keyboard ('Home', 'End', 'ArrowRight', …);
|
||||
* a key commits like a released drag.
|
||||
*/
|
||||
pressSlider(name: EditorControl, key: string): Promise<void>;
|
||||
text: TextPane;
|
||||
/** The heading text, which carries no testid. */
|
||||
heading(): Locator;
|
||||
/** A disabled Save swallows pointer events, so this hovers its tooltip trigger. */
|
||||
hoverSave(): Promise<void>;
|
||||
query: QueryPane;
|
||||
preview: {
|
||||
root(): Locator;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
chart: Chart;
|
||||
markdown: Markdown;
|
||||
};
|
||||
}
|
||||
|
||||
function isDashboardPatch(method: string, url: string): boolean {
|
||||
return method === 'PATCH' && /\/api\/v2\/dashboards\//.test(url);
|
||||
}
|
||||
|
||||
function editorDriver(page: Page): PanelEditor {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
page.getByTestId(`panel-editor-v2-${suffix}`);
|
||||
const preview = (): Locator => page.getByTestId('preview-pane');
|
||||
const sectionToggle = (title: string): Locator =>
|
||||
page.getByTestId(
|
||||
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
);
|
||||
|
||||
const editor: PanelEditor = {
|
||||
root: () => page.locator(EDITOR_ROOT),
|
||||
title: () => byTestId('title'),
|
||||
description: () => byTestId('description'),
|
||||
saveButton: () => byTestId('save'),
|
||||
closeButton: () => byTestId('close'),
|
||||
unsavedBadge: () => byTestId('unsaved-badge'),
|
||||
switchToView: () => byTestId('switch-to-view'),
|
||||
async save() {
|
||||
const { ok, body } = await editor.trySave();
|
||||
// A rejected patch leaves the editor open, which would otherwise surface
|
||||
// as an unrelated timeout several lines later.
|
||||
expect(ok, `dashboard PATCH failed: ${body}`).toBe(true);
|
||||
},
|
||||
async trySave() {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) =>
|
||||
isDashboardPatch(r.request().method(), r.url()),
|
||||
),
|
||||
byTestId('save').click(),
|
||||
]);
|
||||
return { ok: response.ok(), body: await response.text() };
|
||||
},
|
||||
async close(options) {
|
||||
await byTestId('close').click();
|
||||
if (!options?.expectDirty) {
|
||||
return;
|
||||
}
|
||||
await expect(byTestId('discard-modal')).toBeVisible();
|
||||
await byTestId(
|
||||
options.keepEditing ? 'discard-cancel' : 'discard-confirm',
|
||||
).click();
|
||||
},
|
||||
discardModal: () => byTestId('discard-modal'),
|
||||
capturePatches() {
|
||||
const batches: PatchOperation[][] = [];
|
||||
page.on('request', (request) => {
|
||||
if (!isDashboardPatch(request.method(), request.url())) {
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as PatchOperation[] | null;
|
||||
if (body) {
|
||||
batches.push(body);
|
||||
}
|
||||
});
|
||||
return batches;
|
||||
},
|
||||
section: {
|
||||
toggle: sectionToggle,
|
||||
async expand(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
},
|
||||
async collapse(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
},
|
||||
},
|
||||
control: (name) => byTestId(name),
|
||||
tile: (name, value) => byTestId(`${name}-${value}`),
|
||||
async setTile(name, value) {
|
||||
await editor.tile(name, value).click();
|
||||
},
|
||||
tileInput: (name, value) => editor.tile(name, value).locator('input'),
|
||||
blocksUnload: () =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}),
|
||||
async pressSlider(name, key) {
|
||||
const thumb = byTestId(name).getByRole('slider');
|
||||
await thumb.focus();
|
||||
await thumb.press(key);
|
||||
},
|
||||
text: textPaneAt(page),
|
||||
...selectsAt(page),
|
||||
heading: () => editor.root().getByText(EditorText.title),
|
||||
async hoverSave() {
|
||||
await page
|
||||
.locator('[data-slot="tooltip-trigger"]')
|
||||
.filter({ has: byTestId('save') })
|
||||
.hover();
|
||||
},
|
||||
...typeSwitcherAt(page),
|
||||
query: queryPaneAt(page, EditorText.runQuery),
|
||||
preview: {
|
||||
root: preview,
|
||||
renderer: (kind) => preview().getByTestId(RENDERER_TESTID[kind]),
|
||||
chart: chartAt(page, () => preview().getByTestId('uplot-main-div')),
|
||||
markdown: markdownAt(() =>
|
||||
preview().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
const withEditor = driver('editor', ({ page }) => editorDriver(page));
|
||||
const withNewPanel = driver('newPanel', ({ page }) => newPanelDriver(page));
|
||||
|
||||
export const test = withNewPanel(withEditor(panelsTest));
|
||||
|
||||
export * from './panels';
|
||||
export * from './panel-editor/picker';
|
||||
export * from './panel-editor/query';
|
||||
export * from './panel-editor/select';
|
||||
export * from './panel-editor/text';
|
||||
export * from './panel-editor/type-switcher';
|
||||
131
tests/e2e/drivers/panel-editor/picker.ts
generated
131
tests/e2e/drivers/panel-editor/picker.ts
generated
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-spec';
|
||||
import { PATH } from '../dashboards';
|
||||
|
||||
const NEW_PANEL_DRAWER = 'panel-type-drawer';
|
||||
|
||||
/** The type browser's catalog, in render order. */
|
||||
export const PANEL_TYPE_TILES: {
|
||||
kind: PanelKind;
|
||||
label: string;
|
||||
category: string;
|
||||
}[] = [
|
||||
{
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
category: 'Distributions',
|
||||
},
|
||||
{ kind: PanelKind.Heatmap, label: 'Heatmap', category: 'Distributions' },
|
||||
{ kind: PanelKind.Number, label: 'Number', category: 'Single values' },
|
||||
{ kind: PanelKind.Table, label: 'Table', category: 'Raw records' },
|
||||
{ kind: PanelKind.List, label: 'List', category: 'Raw records' },
|
||||
{ kind: PanelKind.Text, label: 'Text', category: 'Documentation' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The New panel drawer every Add panel trigger opens. A tile click only selects
|
||||
* (TimeSeries is preselected); the confirm button navigates to the editor.
|
||||
*/
|
||||
export interface NewPanelPicker {
|
||||
root(): Locator;
|
||||
tile(kind: PanelKind): Locator;
|
||||
/** Visible tiles, in render order. */
|
||||
tiles(): Locator;
|
||||
/** Visible group headings, in render order. */
|
||||
groups(): Locator;
|
||||
search(): Locator;
|
||||
/** A category chip; its accessible name carries the count. */
|
||||
category(label: string): Locator;
|
||||
emptyState(): Locator;
|
||||
/** Reads "Add panel", or "Add to <section>" once there is a choice. */
|
||||
confirmButton(): Locator;
|
||||
/** Selects without confirming. */
|
||||
pick(kind: PanelKind): Promise<void>;
|
||||
confirm(): Promise<void>;
|
||||
/** Selects and confirms, landing on the editor route. */
|
||||
choose(kind: PanelKind): Promise<void>;
|
||||
/** `value` is the layoutIndex, or 'root' for a root yet to be created. */
|
||||
chooseSection(value: string): Promise<void>;
|
||||
/** Switches the target to a new section named `title`. */
|
||||
newSection(title: string): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
|
||||
sectionAddPanel(): Locator;
|
||||
}
|
||||
|
||||
export function newPanelDriver(page: Page): NewPanelPicker {
|
||||
const root = (): Locator => page.getByTestId(NEW_PANEL_DRAWER);
|
||||
const tiles = (): Locator =>
|
||||
root().locator('[data-testid^="panel-type-signoz/"]');
|
||||
|
||||
const picker: NewPanelPicker = {
|
||||
root,
|
||||
tile: (kind) => root().getByTestId(`panel-type-${kind}`),
|
||||
tiles,
|
||||
groups: () => root().locator('section > span:first-child'),
|
||||
search: () => root().getByTestId('panel-type-search'),
|
||||
category: (label) =>
|
||||
root()
|
||||
.locator('button[aria-pressed]:not([data-testid])')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(label)}\\d+$`) }),
|
||||
emptyState: () => root().getByText(/No panel types match/),
|
||||
confirmButton: () => root().getByTestId('panel-type-confirm'),
|
||||
async pick(kind) {
|
||||
await picker.tile(kind).click();
|
||||
await expect(picker.tile(kind)).toHaveAttribute('aria-pressed', 'true');
|
||||
},
|
||||
async confirm() {
|
||||
await picker.confirmButton().click();
|
||||
await page.waitForURL(PATH.newPanel);
|
||||
},
|
||||
async choose(kind) {
|
||||
await picker.pick(kind);
|
||||
await picker.confirm();
|
||||
},
|
||||
async chooseSection(value) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId(`panel-section-option-${value}`).click();
|
||||
},
|
||||
async newSection(title) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId('panel-section-create').click();
|
||||
await root().getByTestId('panel-section-name').fill(title);
|
||||
},
|
||||
async cancel() {
|
||||
await root().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
},
|
||||
sectionAddPanel: () =>
|
||||
page.locator('[data-testid^="section-add-panel-"]').first(),
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
63
tests/e2e/drivers/panel-editor/query.ts
generated
63
tests/e2e/drivers/panel-editor/query.ts
generated
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface QueryPane {
|
||||
builder(): Locator;
|
||||
tab(label: string): Locator;
|
||||
/** Has no testid at its call site. */
|
||||
runButton(): Locator;
|
||||
/** Clicks Run and waits for the response. */
|
||||
run(): Promise<void>;
|
||||
/** Ctrl/Cmd+Enter from inside the builder; waits for the response. */
|
||||
runShortcut(): Promise<void>;
|
||||
/** Picks a metric in the aggregation at `index`; options load as you type. */
|
||||
selectMetric(metricName: string, index?: number): Promise<void>;
|
||||
metricInput(index?: number): Locator;
|
||||
}
|
||||
|
||||
export function queryPaneAt(page: Page, runLabel: string): QueryPane {
|
||||
const builder = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-query-builder');
|
||||
const pane: QueryPane = {
|
||||
builder: () => builder(),
|
||||
// exact: 'Query Builder' is also a prefix of the 'AI Query Builder' tab.
|
||||
tab: (label) => builder().getByRole('tab', { name: label, exact: true }),
|
||||
runButton: () => page.getByRole('button', { name: runLabel }),
|
||||
async run() {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/query_range')),
|
||||
pane.runButton().click(),
|
||||
]);
|
||||
},
|
||||
async runShortcut() {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/query_range')),
|
||||
(async () => {
|
||||
await builder().click();
|
||||
await page.keyboard.press('ControlOrMeta+Enter');
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async selectMetric(metricName, index = 0) {
|
||||
// An antd AutoComplete: the testid is on a wrapper.
|
||||
const field = page.getByTestId(`metric-name-selector-${index}`);
|
||||
await field.click();
|
||||
await field.locator('input').fill(metricName);
|
||||
const dropdown = page.locator(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
||||
);
|
||||
await expect(dropdown).toBeVisible();
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
metricInput: (index = 0) =>
|
||||
page.getByTestId(`metric-name-selector-${index}`).locator('input'),
|
||||
};
|
||||
return pane;
|
||||
}
|
||||
76
tests/e2e/drivers/panel-editor/select.ts
generated
76
tests/e2e/drivers/panel-editor/select.ts
generated
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// antd popups portal to `document.body`, so a testid finds the trigger and the
|
||||
// options live in a detached `.ant-select-dropdown`.
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface EditorSelects {
|
||||
/** Picks an antd Select option by the trigger's testid. */
|
||||
select(triggerTestId: string, optionLabel: string): Promise<void>;
|
||||
/** Types first: long option lists are virtualised. */
|
||||
searchSelect(
|
||||
triggerTestId: string,
|
||||
searchTerm: string,
|
||||
optionLabel: string,
|
||||
): Promise<void>;
|
||||
selectOptions(triggerTestId: string): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
async function openDropdown(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
): Promise<Locator> {
|
||||
const trigger = page.getByTestId(triggerTestId);
|
||||
await trigger.click();
|
||||
// Resolve this Select's own list: a closing dropdown still matches
|
||||
// `:not(.ant-select-dropdown-hidden)`, so two in a row trip strict mode.
|
||||
const listId = await trigger.locator('input').getAttribute('aria-controls');
|
||||
const dropdown = listId
|
||||
? page
|
||||
.locator(`#${listId}`)
|
||||
.locator('xpath=ancestor::div[contains(@class,"ant-select-dropdown")][1]')
|
||||
: page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.last();
|
||||
await expect(dropdown).toBeVisible();
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
export function selectsAt(page: Page): EditorSelects {
|
||||
return {
|
||||
async select(triggerTestId, optionLabel) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
async searchSelect(triggerTestId, searchTerm, optionLabel) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await page.getByTestId(triggerTestId).locator('input').fill(searchTerm);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
async selectOptions(triggerTestId) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
return dropdown.locator('.ant-select-item-option').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label: node.textContent?.trim() ?? '',
|
||||
disabled: node.classList.contains('ant-select-item-option-disabled'),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
50
tests/e2e/drivers/panel-editor/text.ts
generated
50
tests/e2e/drivers/panel-editor/text.ts
generated
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/** The Text kind's editor pane and its own config controls. */
|
||||
export interface TextPane {
|
||||
pane(): Locator;
|
||||
/** The CodeMirror document. */
|
||||
input(): Locator;
|
||||
/**
|
||||
* Replaces the whole body. Inserted as text, not typed, so CodeMirror's
|
||||
* bracket auto-close cannot double `[` or `(`.
|
||||
*/
|
||||
setMarkdown(markdown: string): Promise<void>;
|
||||
toolbar(): Locator;
|
||||
/** Rendered only when the dashboard has variables. */
|
||||
insertVariableButton(): Locator;
|
||||
insertVariable(name: string): Promise<void>;
|
||||
/** The "Hide panel header" switch, inline under the title, not in a section. */
|
||||
hideHeader(): Locator;
|
||||
/** `vertical-horizontal`, e.g. 'center-center'. */
|
||||
alignment(vertical: string, horizontal: string): Locator;
|
||||
}
|
||||
|
||||
export function textPaneAt(page: Page): TextPane {
|
||||
const textPane = (): Locator => page.getByTestId('text-panel-editor-pane');
|
||||
const textInput = (): Locator => textPane().locator('.cm-content');
|
||||
return {
|
||||
pane: textPane,
|
||||
input: textInput,
|
||||
async setMarkdown(markdown) {
|
||||
await textInput().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.insertText(markdown);
|
||||
},
|
||||
toolbar: () => textPane().getByTestId('markdown-editor-toolbar'),
|
||||
insertVariableButton: () =>
|
||||
textPane().getByTestId('markdown-insert-variable'),
|
||||
async insertVariable(name) {
|
||||
await textPane().getByTestId('markdown-insert-variable').click();
|
||||
await page.getByTestId(`markdown-variable-${name}`).click();
|
||||
},
|
||||
hideHeader: () => page.getByTestId('panel-header-hide'),
|
||||
alignment: (vertical, horizontal) =>
|
||||
page.getByTestId(`text-layout-align-${vertical}-${horizontal}`),
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-spec';
|
||||
|
||||
import { type SelectOption } from './select';
|
||||
|
||||
const TYPE_SWITCHER_DRAWER = 'panel-type-switcher-drawer';
|
||||
|
||||
export interface TypeSwitcher {
|
||||
typeSwitcher(): Locator;
|
||||
/** Opens the kind picker drawer and picks one. */
|
||||
switchType(kind: PanelKind): Promise<void>;
|
||||
/** A tile in the open type-switcher drawer. */
|
||||
typeOption(kind: PanelKind): Locator;
|
||||
openTypeSwitcher(): Promise<Locator>;
|
||||
/** Every tile in the type switcher, opening it; `disabled` reads aria-disabled. */
|
||||
typeOptions(): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles are aria-disabled, not disabled, so their reason tooltip still hovers.
|
||||
* The label is the display name alone: the tile also renders a preview and a
|
||||
* description.
|
||||
*/
|
||||
async function tileOptions(scope: Locator): Promise<SelectOption[]> {
|
||||
const tiles = scope.locator('[data-testid^="panel-type-signoz/"]');
|
||||
await expect(tiles.first()).toBeVisible();
|
||||
return tiles.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label:
|
||||
node
|
||||
.querySelector(
|
||||
':scope > span:last-child > span:first-child > span:first-child',
|
||||
)
|
||||
?.textContent?.trim() ?? '',
|
||||
disabled: node.getAttribute('aria-disabled') === 'true',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function typeSwitcherAt(page: Page): TypeSwitcher {
|
||||
const trigger = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-type-switcher');
|
||||
const switcher: TypeSwitcher = {
|
||||
typeSwitcher: trigger,
|
||||
typeOption: (kind) =>
|
||||
page.getByTestId(TYPE_SWITCHER_DRAWER).getByTestId(`panel-type-${kind}`),
|
||||
async openTypeSwitcher() {
|
||||
await trigger().click();
|
||||
const drawer = page.getByTestId(TYPE_SWITCHER_DRAWER);
|
||||
await expect(drawer).toBeVisible();
|
||||
return drawer;
|
||||
},
|
||||
async switchType(kind) {
|
||||
await switcher.openTypeSwitcher();
|
||||
await switcher.typeOption(kind).click();
|
||||
await expect(page.getByTestId(TYPE_SWITCHER_DRAWER)).toHaveCount(0);
|
||||
},
|
||||
async typeOptions() {
|
||||
const drawer = await switcher.openTypeSwitcher();
|
||||
return tileOptions(drawer);
|
||||
},
|
||||
};
|
||||
return switcher;
|
||||
}
|
||||
568
tests/e2e/drivers/panel-kinds.ts
generated
568
tests/e2e/drivers/panel-kinds.ts
generated
@@ -1,568 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
import {
|
||||
buildDashboard,
|
||||
type EditorControl,
|
||||
GOLDEN,
|
||||
heatmapPoints,
|
||||
heatmapTooltipAt,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
NEW_KINDS_MARKDOWN,
|
||||
type Panel,
|
||||
panel,
|
||||
type PanelHandle,
|
||||
PanelKind,
|
||||
type PostableDashboard,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
type QueryRangeMock,
|
||||
QueryTab,
|
||||
ramp,
|
||||
rawQuery,
|
||||
RENDERER_TESTID,
|
||||
Section,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
type ThresholdVariant,
|
||||
} from './panel-config';
|
||||
|
||||
// One row per panel kind, every value read off the kind's definition.ts and
|
||||
// sections.ts (and capabilities.ts for the query matrix). The contract specs
|
||||
// generate one test per row, so a new kind is one row here.
|
||||
|
||||
export type QueryType = 'builder' | 'clickhouse' | 'promql';
|
||||
|
||||
export type DownloadFormat = 'CSV' | 'PNG' | 'SVG';
|
||||
|
||||
/**
|
||||
* Every collapsible section title. Text's PanelHeader section is left out: it
|
||||
* renders inline in Panel Details, with no toggle of its own.
|
||||
*/
|
||||
export const ALL_SECTIONS: string[] = Object.values(Section);
|
||||
|
||||
/** The axis controls a kind can declare; Heatmap has `y-scale` only. */
|
||||
export const AXIS_CONTROLS: EditorControl[] = [
|
||||
'soft-min',
|
||||
'soft-max',
|
||||
'log-scale',
|
||||
'y-scale',
|
||||
];
|
||||
|
||||
export interface PanelKindCase {
|
||||
kind: PanelKind;
|
||||
/** `displayName`: the picker tile, switcher option and tooltip wording. */
|
||||
label: string;
|
||||
renderer: string;
|
||||
/** Renders from its own spec: no query, so no data states. */
|
||||
queryless: boolean;
|
||||
/** The panel as seeded; renders against golden data. */
|
||||
panel(): Panel;
|
||||
/** A one-panel dashboard holding `panel()` under SINGLE_PANEL_ID. */
|
||||
seed(): PostableDashboard;
|
||||
/** A mocked result the kind renders; null when queryless. */
|
||||
body(): QueryRangeBody | null;
|
||||
/** A mocked successful result with nothing in it; null when queryless. */
|
||||
emptyBody(): QueryRangeBody | null;
|
||||
/** Number renders the shared empty state under its own testid. */
|
||||
noDataTestId: string;
|
||||
sections: string[];
|
||||
forbiddenSections: string[];
|
||||
/** Visualization declares the time-preference control. */
|
||||
timePreference: boolean;
|
||||
/** Axis controls shown once Axes opens; the rest of AXIS_CONTROLS are absent. */
|
||||
axisControls: EditorControl[];
|
||||
queryTabs: string[];
|
||||
/** Query types the switcher accepts, AI excluded. */
|
||||
queryTypes: QueryType[];
|
||||
/** Builder signals the switcher accepts. */
|
||||
signals: Signal[];
|
||||
downloads: DownloadFormat[];
|
||||
createAlert: boolean;
|
||||
drilldown: boolean;
|
||||
/** The kind's data gesture: what a drilldown would hang off. */
|
||||
click(target: PanelHandle): Promise<void>;
|
||||
headerSearch: boolean;
|
||||
thresholdVariant: ThresholdVariant | null;
|
||||
columnsEditor: boolean;
|
||||
}
|
||||
|
||||
const BUCKETS = [10, 50, 100, 500];
|
||||
|
||||
const ALL_SIGNALS: Signal[] = ['metrics', 'logs', 'traces'];
|
||||
const ALL_TABS = [
|
||||
QueryTab.builder,
|
||||
QueryTab.clickhouse,
|
||||
QueryTab.promql,
|
||||
QueryTab.ai,
|
||||
];
|
||||
const IMAGES: DownloadFormat[] = ['PNG', 'SVG'];
|
||||
|
||||
const CHART_SECTIONS = [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
];
|
||||
|
||||
function oneSeries(): QueryRangeBody {
|
||||
return QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]);
|
||||
}
|
||||
|
||||
function serviceScalar(rows: (string | number)[][]): QueryRangeBody {
|
||||
return QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
function singlePanel(content: Panel): PostableDashboard {
|
||||
return buildDashboard({
|
||||
sections: [{ title: 'Section', panels: { [SINGLE_PANEL_ID]: content } }],
|
||||
});
|
||||
}
|
||||
|
||||
type RowInput = Omit<
|
||||
PanelKindCase,
|
||||
'renderer' | 'seed' | 'forbiddenSections' | 'noDataTestId'
|
||||
> &
|
||||
Partial<Pick<PanelKindCase, 'noDataTestId'>>;
|
||||
|
||||
function row(input: RowInput): PanelKindCase {
|
||||
return {
|
||||
noDataTestId: 'panel-no-data',
|
||||
...input,
|
||||
renderer: RENDERER_TESTID[input.kind],
|
||||
seed: () => singlePanel(input.panel()),
|
||||
forbiddenSections: ALL_SECTIONS.filter(
|
||||
(title) => !input.sections.includes(title),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const clickChartPoint = (target: PanelHandle): Promise<void> =>
|
||||
target.chart.clickPoint();
|
||||
const clickChart = (target: PanelHandle): Promise<void> => target.chart.click();
|
||||
|
||||
/**
|
||||
* A slice's bounding-box centre can fall in the donut hole, so this clicks the
|
||||
* centroid, where the slice's label leader line starts.
|
||||
*/
|
||||
async function clickPieSlice(target: PanelHandle): Promise<void> {
|
||||
const leader = target
|
||||
.renderer(PanelKind.PieChart)
|
||||
.locator('svg g line')
|
||||
.first();
|
||||
const { x, y } = await leader.evaluate((node) => {
|
||||
const line = node as SVGLineElement;
|
||||
const matrix = line.getScreenCTM();
|
||||
const point = new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix ?? undefined);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
await target.root().page().mouse.click(x, y);
|
||||
}
|
||||
|
||||
/** Waits for the cell tooltip, so a missing menu is not a click that missed. */
|
||||
async function clickHeatmapCell(target: PanelHandle): Promise<void> {
|
||||
await target.chart.click();
|
||||
await expect(heatmapTooltipAt(target.root().page()).root()).toBeVisible();
|
||||
}
|
||||
|
||||
export const PANEL_KINDS: PanelKindCase[] = [
|
||||
row({
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.TimeSeries, { name: 'Calls by service' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.BarChart, { name: 'Calls bar' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: CHART_SECTIONS,
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.AreaChart, { name: 'Calls area' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({ metricName: GOLDEN.metrics.latencySum, groupBy: [] }),
|
||||
}),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.legend,
|
||||
Section.buckets,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: false,
|
||||
click: clickChart,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Number,
|
||||
label: 'Number',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
body: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [[1234]] }),
|
||||
emptyBody: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [] }),
|
||||
noDataTestId: 'number-panel-no-data',
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
// The whole renderer is the click target.
|
||||
click: (target) => target.renderer(PanelKind.Number).click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'comparison',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.PieChart, { name: 'Calls share' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.legend,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: clickPieSlice,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Table,
|
||||
label: 'Table',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.Table, { name: 'Calls table' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: ['CSV', ...IMAGES],
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: (target) => target.table.rows().first().locator('td').last().click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: 'table',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.List,
|
||||
label: 'List',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
body: () =>
|
||||
QueryRange.raw(
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
body: `log line ${i}`,
|
||||
'service.name': 'adservice',
|
||||
})),
|
||||
),
|
||||
emptyBody: () => QueryRange.raw([]),
|
||||
sections: [Section.visualization],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.ai],
|
||||
queryTypes: ['builder'],
|
||||
signals: ['logs', 'traces'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) =>
|
||||
target.renderer(PanelKind.List).getByText('log line 0').click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: true,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Heatmap,
|
||||
label: 'Heatmap',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Heatmap, { name: 'Calls heatmap', query: heatmapQuery() }),
|
||||
body: () =>
|
||||
QueryRange.heatmap(BUCKETS, [
|
||||
{
|
||||
labels: { 'service.name': 'adservice' },
|
||||
points: heatmapPoints(12, BUCKETS.length + 1),
|
||||
},
|
||||
]),
|
||||
// Bucket bounds but no series: the heatmap's own empty body.
|
||||
emptyBody: () => QueryRange.heatmap(BUCKETS, []),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.chartAppearance,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: ['y-scale'],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.promql],
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ['metrics'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: clickHeatmapCell,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Text,
|
||||
label: 'Text',
|
||||
queryless: true,
|
||||
panel: () =>
|
||||
panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
body: () => null,
|
||||
emptyBody: () => null,
|
||||
sections: [Section.visualization, Section.panelAppearance],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [],
|
||||
queryTypes: [],
|
||||
signals: [],
|
||||
downloads: [],
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) => target.markdown.root().click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export interface NoDataState {
|
||||
root(): Locator;
|
||||
action(): Locator;
|
||||
secondaryAction(): Locator;
|
||||
}
|
||||
|
||||
/** The kind's empty state; its actions take the state's testid as a prefix. */
|
||||
export function noDataOf(c: PanelKindCase, target: PanelHandle): NoDataState {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
target.root().getByTestId(`${c.noDataTestId}${suffix}`);
|
||||
return {
|
||||
root: () => byTestId(''),
|
||||
action: () => byTestId('-action'),
|
||||
secondaryAction: () => byTestId('-secondary-action'),
|
||||
};
|
||||
}
|
||||
|
||||
export const QUERYABLE_KINDS = PANEL_KINDS.filter((c) => !c.queryless);
|
||||
|
||||
export function kindCase(kind: PanelKind): PanelKindCase {
|
||||
const match = PANEL_KINDS.find((c) => c.kind === kind);
|
||||
if (!match) {
|
||||
throw new Error(`no PANEL_KINDS row for ${kind}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's panel under SINGLE_PANEL_ID, below a tall queryless filler so it
|
||||
* starts outside the viewport.
|
||||
*/
|
||||
export function belowTheFold(c: PanelKindCase): PostableDashboard {
|
||||
const dashboard = buildDashboard({
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
filler: panel(PanelKind.Text, { name: 'Filler' }),
|
||||
[SINGLE_PANEL_ID]: c.panel(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const [layout] = dashboard.spec.layouts;
|
||||
layout.spec.items = [
|
||||
{ ...layout.spec.items[0], x: 0, y: 0, width: 12, height: 40 },
|
||||
{ ...layout.spec.items[1], x: 0, y: 40 },
|
||||
];
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/** Serves the kind's mocked result; a queryless kind sends no query to serve. */
|
||||
export async function respondWithData(
|
||||
queryRange: QueryRangeMock,
|
||||
c: PanelKindCase,
|
||||
): Promise<void> {
|
||||
const body = c.body();
|
||||
if (body) {
|
||||
await queryRange.respond(body);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind's panel plus an empty second section, so Move to section has a target. */
|
||||
export function withSpareSection(c: PanelKindCase): PostableDashboard {
|
||||
return buildDashboard({
|
||||
sections: [
|
||||
{ title: 'Section', panels: { [SINGLE_PANEL_ID]: c.panel() } },
|
||||
{ title: 'Spare', panels: {} },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher's reason for refusing `c`, or null when it accepts: the query
|
||||
* type is checked before the signal (getPanelTypeDisabledReason).
|
||||
*/
|
||||
export function disabledReason(
|
||||
c: PanelKindCase,
|
||||
source: { queryType: QueryType; signal?: Signal },
|
||||
): string | null {
|
||||
if (c.queryless) {
|
||||
return null;
|
||||
}
|
||||
if (!c.queryTypes.includes(source.queryType)) {
|
||||
const label = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse',
|
||||
promql: 'PromQL',
|
||||
}[source.queryType];
|
||||
return `${c.label} isn't available for ${label} queries`;
|
||||
}
|
||||
if (source.signal && !c.signals.includes(source.signal)) {
|
||||
return `${c.label} doesn't support ${source.signal} data`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export * from './panel-config';
|
||||
274
tests/e2e/drivers/panels.ts
generated
274
tests/e2e/drivers/panels.ts
generated
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-spec';
|
||||
|
||||
import { test as dashboardsTest } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { actionsAt, PanelAction, type PanelActions } from './panels/actions';
|
||||
import { type Chart, chartAt } from './panels/chart';
|
||||
import { openSelectOption } from './panels/dom';
|
||||
import { gridAt, type PanelGrid } from './panels/grid';
|
||||
import {
|
||||
type HeatmapPanel,
|
||||
type HeatmapTooltip,
|
||||
heatmapAt,
|
||||
heatmapTooltipAt,
|
||||
} from './panels/heatmap';
|
||||
import { type Legend, legendAt } from './panels/legend';
|
||||
import { type Markdown, markdownAt } from './panels/markdown';
|
||||
import { type PiePanel, pieAt } from './panels/pie';
|
||||
import {
|
||||
type ListPager,
|
||||
pagerAt,
|
||||
type PanelSearch,
|
||||
searchAt,
|
||||
type TableColumns,
|
||||
tableAt,
|
||||
} from './panels/table';
|
||||
|
||||
// Rendered panels, on the dashboard grid and in the View modal. Each surface
|
||||
// lives in its own part under ./panels, and so does a kind with UI of its own.
|
||||
|
||||
export const PanelMessageText = {
|
||||
noQueryTitle: 'Nothing to visualize yet',
|
||||
// Curly apostrophe (U+2019): a straight quote will not match.
|
||||
errorTitle: 'Couldn’t load panel data',
|
||||
noDataTitle: 'No data in this time range',
|
||||
extendAction: 'Extend time range',
|
||||
retryAction: 'Retry',
|
||||
} as const;
|
||||
|
||||
/** Root testid each kind's renderer puts on its own subtree. */
|
||||
export const RENDERER_TESTID: Record<PanelKind, string> = {
|
||||
[PanelKind.TimeSeries]: 'time-series-renderer',
|
||||
[PanelKind.BarChart]: 'bar-panel-renderer',
|
||||
[PanelKind.AreaChart]: 'area-chart-renderer',
|
||||
[PanelKind.Histogram]: 'histogram-panel-renderer',
|
||||
[PanelKind.Number]: 'number-panel-renderer',
|
||||
[PanelKind.PieChart]: 'pie-panel-renderer',
|
||||
[PanelKind.Table]: 'table-panel-renderer',
|
||||
[PanelKind.List]: 'list-panel-renderer',
|
||||
[PanelKind.Heatmap]: 'heatmap-panel-renderer',
|
||||
[PanelKind.Text]: 'text-panel',
|
||||
};
|
||||
|
||||
export interface PanelHandle {
|
||||
root(): Locator;
|
||||
/** Panels fetch lazily behind an IntersectionObserver. */
|
||||
scrollIntoView(): Promise<void>;
|
||||
/** Scrolls in, then waits for the kind's own renderer. */
|
||||
waitRendered(kind: PanelKind): Promise<void>;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
/** `data-panel-visible`, 'true' once the panel entered the viewport. */
|
||||
visibility(): Locator;
|
||||
title(): Locator;
|
||||
infoIcon(): Locator;
|
||||
timePreference(): Locator;
|
||||
statusWarning(): Locator;
|
||||
statusError(): Locator;
|
||||
loading(): Locator;
|
||||
noData(): Locator;
|
||||
noDataAction(): Locator;
|
||||
noDataSecondaryAction(): Locator;
|
||||
error(): Locator;
|
||||
errorAction(): Locator;
|
||||
text(text: string): Locator;
|
||||
numberValue(): Locator;
|
||||
numberNoData(): Locator;
|
||||
/** Replaces the header when a Text panel hides it; carries the drag class. */
|
||||
hiddenHeaderHandle(): Locator;
|
||||
markdown: Markdown;
|
||||
heatmap: HeatmapPanel;
|
||||
pie: PiePanel;
|
||||
chart: Chart;
|
||||
legend: Legend;
|
||||
actions: PanelActions;
|
||||
search: PanelSearch;
|
||||
pager: ListPager;
|
||||
table: TableColumns;
|
||||
grid: PanelGrid;
|
||||
}
|
||||
|
||||
export interface Panels {
|
||||
panel(id: string): PanelHandle;
|
||||
/** Every panel title on the grid. */
|
||||
titles(): Locator;
|
||||
tooltip: {
|
||||
container(): Locator;
|
||||
list(): Locator;
|
||||
unpin(): Locator;
|
||||
/** The pin key is 'p' (DEFAULT_PIN_TOOLTIP_KEY); its JSDoc still says 'l'. */
|
||||
pin(): Promise<void>;
|
||||
/** Hover tooltips render their content twice, so this scopes to the tooltip role. */
|
||||
statusContent(): Locator;
|
||||
/** Free text inside any open tooltip. */
|
||||
text(text: string): Locator;
|
||||
heatmap: HeatmapTooltip;
|
||||
};
|
||||
drilldown: {
|
||||
menu(): Locator;
|
||||
/** Testids sit on an inner span; this walks up so disabled states assert. */
|
||||
item(testId: string): Locator;
|
||||
back(menu: 'breakout' | 'var'): Locator;
|
||||
breakoutSearch(): Locator;
|
||||
/** A page-level Escape does nothing: the handler needs the backdrop. */
|
||||
dismiss(): Promise<void>;
|
||||
};
|
||||
view: {
|
||||
/** Opens the modal through the actions menu. */
|
||||
open(panelId: string): Promise<Locator>;
|
||||
content(): Locator;
|
||||
dialog(): Locator;
|
||||
refresh(): Locator;
|
||||
switchToEdit(): Locator;
|
||||
chart: Chart;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
markdown: Markdown;
|
||||
/** Text only: the Markdown editor in the query builder's slot. */
|
||||
textEditor(): Locator;
|
||||
/** Changes the kind for this session only. */
|
||||
switchType(label: string): Promise<void>;
|
||||
};
|
||||
/** A visible option of the open antd Select. */
|
||||
openSelectOption(label: string): Locator;
|
||||
/** Fires the browser's Back. */
|
||||
back(): Promise<void>;
|
||||
pressEscape(): Promise<void>;
|
||||
/** Resolves with the tab a click opens. */
|
||||
nextTab(): Promise<Page>;
|
||||
/** Resolves with the suggested filename of the next download. */
|
||||
nextDownload(): Promise<{ suggestedFilename: () => string }>;
|
||||
}
|
||||
|
||||
function panelHandle(page: Page, id: string): PanelHandle {
|
||||
const root = (): Locator => page.locator(`[data-panel-root="${id}"]`);
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
root,
|
||||
async scrollIntoView() {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
},
|
||||
async waitRendered(kind) {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
await expect(root()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(byTestId(RENDERER_TESTID[kind])).toBeVisible();
|
||||
},
|
||||
renderer: (kind) => byTestId(RENDERER_TESTID[kind]),
|
||||
visibility: root,
|
||||
title: () => byTestId('panel-title'),
|
||||
infoIcon: () => byTestId('panel-header-info-icon'),
|
||||
timePreference: () => byTestId('panel-time-preference'),
|
||||
statusWarning: () => byTestId('panel-status-warning'),
|
||||
statusError: () => byTestId('panel-status-error'),
|
||||
loading: () => byTestId('panel-loading'),
|
||||
noData: () => byTestId('panel-no-data'),
|
||||
noDataAction: () => byTestId('panel-no-data-action'),
|
||||
noDataSecondaryAction: () => byTestId('panel-no-data-secondary-action'),
|
||||
error: () => byTestId('panel-error'),
|
||||
errorAction: () => byTestId('panel-error-action'),
|
||||
text: (text) => root().getByText(text),
|
||||
numberValue: () => byTestId('number-panel-value'),
|
||||
numberNoData: () => byTestId('number-panel-no-data'),
|
||||
hiddenHeaderHandle: () => byTestId('hidden-header-drag-handle'),
|
||||
markdown: markdownAt(() => byTestId(RENDERER_TESTID[PanelKind.Text])),
|
||||
heatmap: heatmapAt(root),
|
||||
pie: pieAt(root),
|
||||
chart: chartAt(page, () => byTestId('uplot-main-div')),
|
||||
legend: legendAt(root),
|
||||
actions: actionsAt(page, id, root),
|
||||
search: searchAt(root),
|
||||
pager: pagerAt(page, root),
|
||||
table: tableAt(page, id, root),
|
||||
grid: gridAt(page, root),
|
||||
};
|
||||
}
|
||||
|
||||
function panelsDriver(page: Page): Panels {
|
||||
const menu = (): Locator => page.locator('.context-menu');
|
||||
const viewContent = (): Locator =>
|
||||
page.getByTestId('view-panel-modal-content');
|
||||
|
||||
return {
|
||||
panel: (id) => panelHandle(page, id),
|
||||
titles: () => page.getByTestId('panel-title'),
|
||||
tooltip: {
|
||||
container: () => page.getByTestId('uplot-tooltip-container'),
|
||||
list: () => page.getByTestId('uplot-tooltip-list'),
|
||||
unpin: () => page.getByTestId('uplot-tooltip-unpin'),
|
||||
async pin() {
|
||||
await page.keyboard.press('p');
|
||||
},
|
||||
statusContent: () =>
|
||||
page.getByRole('tooltip').getByTestId('panel-status-content'),
|
||||
text: (text) => page.getByText(text),
|
||||
heatmap: heatmapTooltipAt(page),
|
||||
},
|
||||
drilldown: {
|
||||
menu,
|
||||
item: (testId) =>
|
||||
page
|
||||
.getByTestId(testId)
|
||||
.locator(
|
||||
'xpath=ancestor-or-self::button[contains(@class,"context-menu-item")][1]',
|
||||
),
|
||||
back: (submenu) => page.getByTestId(`drilldown-${submenu}-back`),
|
||||
breakoutSearch: () => page.getByPlaceholder('Search breakout options...'),
|
||||
async dismiss() {
|
||||
// A corner, since the menu opens over the clicked point.
|
||||
await page
|
||||
.locator('.context-menu-backdrop')
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
},
|
||||
},
|
||||
view: {
|
||||
async open(panelId) {
|
||||
await panelHandle(page, panelId).actions.run(PanelAction.view);
|
||||
await expect(viewContent()).toBeVisible();
|
||||
return viewContent();
|
||||
},
|
||||
content: viewContent,
|
||||
dialog: () => page.getByRole('dialog'),
|
||||
refresh: () => page.getByTestId('view-panel-refresh'),
|
||||
switchToEdit: () => page.getByTestId('view-panel-switch-to-edit'),
|
||||
chart: chartAt(page, () => viewContent().getByTestId('uplot-main-div')),
|
||||
renderer: (kind) => viewContent().getByTestId(RENDERER_TESTID[kind]),
|
||||
markdown: markdownAt(() =>
|
||||
viewContent().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
textEditor: () => viewContent().getByTestId('text-panel-editor-pane'),
|
||||
async switchType(label) {
|
||||
await page.getByTestId('view-panel-type-selector').click();
|
||||
await openSelectOption(page, label).click();
|
||||
},
|
||||
},
|
||||
openSelectOption: (label) => openSelectOption(page, label),
|
||||
async back() {
|
||||
await page.goBack();
|
||||
},
|
||||
async pressEscape() {
|
||||
await page.keyboard.press('Escape');
|
||||
},
|
||||
nextTab: () => page.context().waitForEvent('page'),
|
||||
nextDownload: () => page.waitForEvent('download'),
|
||||
};
|
||||
}
|
||||
|
||||
const withPanels = driver('panels', ({ page }) => panelsDriver(page));
|
||||
|
||||
export const test = withPanels(dashboardsTest);
|
||||
|
||||
export * from './dashboards';
|
||||
export * from './panels/actions';
|
||||
export * from './panels/chart';
|
||||
export * from './panels/dom';
|
||||
export * from './panels/grid';
|
||||
export * from './panels/heatmap';
|
||||
export * from './panels/legend';
|
||||
export * from './panels/markdown';
|
||||
export * from './panels/pie';
|
||||
export * from './panels/table';
|
||||
88
tests/e2e/drivers/panels/actions.ts
generated
88
tests/e2e/drivers/panels/actions.ts
generated
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// Panel action menu items take no testid (@signozhq/ui's dropdown preset drops
|
||||
// it), so their visible label is the contract, matched by Radix's menuitem role.
|
||||
|
||||
export const PanelAction = {
|
||||
view: 'View',
|
||||
edit: 'Edit panel',
|
||||
clone: 'Clone',
|
||||
download: 'Download',
|
||||
downloadCsv: 'Download as CSV',
|
||||
downloadPng: 'Download as PNG',
|
||||
downloadSvg: 'Download as SVG',
|
||||
createAlert: 'Create Alerts',
|
||||
move: 'Move to section',
|
||||
moveToRoot: 'Dashboard (root)',
|
||||
delete: 'Delete panel',
|
||||
} as const;
|
||||
|
||||
export interface PanelActions {
|
||||
trigger(): Locator;
|
||||
/** Dismisses any menu still open first: one caught mid-dismiss eats the click. */
|
||||
open(): Promise<Locator>;
|
||||
close(): Promise<void>;
|
||||
item(label: string): Locator;
|
||||
run(label: string): Promise<void>;
|
||||
/** Hovers a parent item to open its submenu; clicking it closes the menu. */
|
||||
hoverItem(label: string): Promise<void>;
|
||||
download(format: 'CSV' | 'PNG' | 'SVG'): Promise<void>;
|
||||
moveTo(sectionTitle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function actionsAt(
|
||||
page: Page,
|
||||
panelId: string,
|
||||
root: () => Locator,
|
||||
): PanelActions {
|
||||
const menuItem = (label: string): Locator =>
|
||||
page.getByRole('menuitem', { name: label, exact: true });
|
||||
|
||||
const actions: PanelActions = {
|
||||
trigger: () => page.getByTestId(`panel-actions-${panelId}`),
|
||||
async open() {
|
||||
await actions.close();
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
// The trigger only appears on hover.
|
||||
await root().hover();
|
||||
await actions.trigger().click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
},
|
||||
async close() {
|
||||
if ((await page.getByRole('menu').count()) === 0) {
|
||||
return;
|
||||
}
|
||||
// An open submenu takes the first Escape for itself.
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menu')).toHaveCount(0, { timeout: 1_000 });
|
||||
}).toPass();
|
||||
},
|
||||
item: menuItem,
|
||||
async run(label) {
|
||||
await actions.open();
|
||||
await menuItem(label).click();
|
||||
},
|
||||
async hoverItem(label) {
|
||||
await menuItem(label).hover();
|
||||
},
|
||||
async download(format) {
|
||||
await actions.open();
|
||||
await menuItem(PanelAction.download).hover();
|
||||
await menuItem(`Download as ${format}`).click();
|
||||
},
|
||||
async moveTo(sectionTitle) {
|
||||
await actions.open();
|
||||
await menuItem(PanelAction.move).hover();
|
||||
await menuItem(sectionTitle).click();
|
||||
},
|
||||
};
|
||||
|
||||
return actions;
|
||||
}
|
||||
287
tests/e2e/drivers/panels/chart.ts
generated
287
tests/e2e/drivers/panels/chart.ts
generated
@@ -1,287 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragHorizontally } from './dom';
|
||||
|
||||
/** uPlot encodes a log scale as `distr: 3`. */
|
||||
export const LOG_DISTR = 3;
|
||||
|
||||
export interface UPlotSeriesState {
|
||||
label?: string;
|
||||
/** false once a series is hidden via the legend. */
|
||||
show: boolean;
|
||||
/** Non-empty for a dashed line style. */
|
||||
dash?: number[];
|
||||
/** uPlot only sets a fill when the panel asks for one. */
|
||||
hasFill: boolean;
|
||||
/** The resolved fill: a colour (`#rrggbbaa` when solid) or 'gradient'. */
|
||||
fill: string | null;
|
||||
/** Largest finite value plotted, after any stacking; null when none. */
|
||||
max: number | null;
|
||||
/** A predicate cannot cross `evaluate`, so it reports as 'fn'. */
|
||||
pointsShow?: boolean | 'fn' | null;
|
||||
width?: number;
|
||||
/** uPlot's own gap bridging; false when a threshold breaks gaps in data-prep. */
|
||||
spanGaps?: boolean | number;
|
||||
/** Nulls in the plotted data: each one is a break in the line. */
|
||||
nullCount: number;
|
||||
}
|
||||
|
||||
export interface UPlotScaleState {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
/** 1 linear, 3 logarithmic. */
|
||||
distr?: number;
|
||||
}
|
||||
|
||||
export interface UPlotState {
|
||||
/** Excludes the x series at index 0. */
|
||||
series: UPlotSeriesState[];
|
||||
scales: Record<string, UPlotScaleState>;
|
||||
/** Points in the x series. */
|
||||
pointCount: number;
|
||||
/** Fill bands between series; a stacked chart has one per adjacent pair. */
|
||||
bandCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real chart state, read from the `__uplot` handle UPlotChart hangs off its
|
||||
* container. The DOM only says a chart exists; this catches a renderer that
|
||||
* ignores a setting.
|
||||
*/
|
||||
export interface Chart {
|
||||
host(): Locator;
|
||||
/** uPlot binds its cursor to `.u-over`, so this is the pointer target. */
|
||||
plotArea(): Locator;
|
||||
/** Throws when there is no plot, usually because the panel shows No Data. */
|
||||
state(): Promise<UPlotState>;
|
||||
/** The left scale, where axis bounds and log mode land. */
|
||||
yScale(): Promise<UPlotScaleState>;
|
||||
expectSeriesCount(count: number): Promise<void>;
|
||||
/**
|
||||
* Share (0–1) of the plot-area row at `value` on the left scale painted in
|
||||
* `color`. For lines drawn straight onto the canvas, such as thresholds,
|
||||
* which leave no state on the uPlot instance.
|
||||
*/
|
||||
lineCoverage(value: number, color: string): Promise<number>;
|
||||
/** Moves the pointer to the middle of the plot. */
|
||||
hover(): Promise<void>;
|
||||
/** Moves the pointer just outside the plot's top-left corner. */
|
||||
leave(): Promise<void>;
|
||||
click(): Promise<void>;
|
||||
/**
|
||||
* Clicks the first series' data point at `index`, by default the middle of
|
||||
* those inside the visible window, placed through uPlot's own scales so it
|
||||
* lands on data wherever it sits.
|
||||
*/
|
||||
clickPoint(index?: number): Promise<void>;
|
||||
/** Fractions are of the plot area's own box, so panel size does not matter. */
|
||||
drag(fromFraction: number, toFraction: number): Promise<void>;
|
||||
}
|
||||
|
||||
async function readUplot(host: Locator): Promise<UPlotState> {
|
||||
await expect(host).toBeVisible();
|
||||
return host.evaluate((node) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
series: {
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
dash?: number[];
|
||||
fill?: unknown;
|
||||
width?: number;
|
||||
spanGaps?: boolean | number;
|
||||
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
|
||||
}[];
|
||||
scales: Record<string, { min?: number; max?: number; distr?: number }>;
|
||||
data: unknown[][];
|
||||
bands?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!plot) {
|
||||
throw new Error(
|
||||
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
|
||||
);
|
||||
}
|
||||
|
||||
// uPlot wraps every fill in a function; calling it yields the colour or gradient.
|
||||
const resolveFill = (fill: unknown, seriesIndex: number): string | null => {
|
||||
const value =
|
||||
typeof fill === 'function'
|
||||
? (fill as (u: unknown, i: number) => unknown)(plot, seriesIndex)
|
||||
: fill;
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return value instanceof CanvasGradient ? 'gradient' : null;
|
||||
};
|
||||
|
||||
const maxOf = (values: unknown[] | undefined): number | null => {
|
||||
const finite = (values ?? []).filter(
|
||||
(value): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value),
|
||||
);
|
||||
return finite.length > 0 ? Math.max(...finite) : null;
|
||||
};
|
||||
|
||||
return {
|
||||
series: plot.series.slice(1).map((series, index) => ({
|
||||
label: series.label,
|
||||
show: series.show !== false,
|
||||
dash: series.dash,
|
||||
hasFill: series.fill != null,
|
||||
fill: resolveFill(series.fill, index + 1),
|
||||
max: maxOf(plot.data?.[index + 1]),
|
||||
pointsShow:
|
||||
typeof series.points?.show === 'function'
|
||||
? ('fn' as const)
|
||||
: (series.points?.show ?? null),
|
||||
width: series.width,
|
||||
spanGaps: series.spanGaps,
|
||||
nullCount: (plot.data?.[index + 1] ?? []).filter((v) => v === null).length,
|
||||
})),
|
||||
scales: Object.fromEntries(
|
||||
Object.entries(plot.scales).map(([key, scale]) => [
|
||||
key,
|
||||
{ min: scale.min ?? null, max: scale.max ?? null, distr: scale.distr },
|
||||
]),
|
||||
),
|
||||
pointCount: plot.data?.[0]?.length ?? 0,
|
||||
bandCount: plot.bands?.length ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function chartAt(page: Page, host: () => Locator): Chart {
|
||||
const plotArea = (): Locator => host().locator('.u-over');
|
||||
const chart: Chart = {
|
||||
host,
|
||||
plotArea,
|
||||
state: () => readUplot(host()),
|
||||
async yScale() {
|
||||
const { scales } = await chart.state();
|
||||
// Panels name the left scale 'y'; fall back to the first non-x scale.
|
||||
return (
|
||||
scales.y ??
|
||||
Object.entries(scales).find(([key]) => key !== 'x')?.[1] ?? {
|
||||
min: null,
|
||||
max: null,
|
||||
}
|
||||
);
|
||||
},
|
||||
async lineCoverage(value, color) {
|
||||
await expect(host()).toBeVisible();
|
||||
return host().evaluate(
|
||||
(node, { value, color }) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
bbox: { left: number; width: number };
|
||||
scales: Record<string, unknown>;
|
||||
valToPos: (v: number, scale: string, canvas: boolean) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const scale =
|
||||
'y' in plot.scales
|
||||
? 'y'
|
||||
: (Object.keys(plot.scales).find((key) => key !== 'x') ?? 'y');
|
||||
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
if (!probe) {
|
||||
throw new Error('no 2d context to resolve the colour');
|
||||
}
|
||||
probe.fillStyle = color;
|
||||
probe.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
// The line is 2px wide and anti-aliased: read the rows around it and
|
||||
// count a column when any of them is close to the colour.
|
||||
const y = Math.round(plot.valToPos(value, scale, true));
|
||||
const x = Math.round(plot.bbox.left);
|
||||
const width = Math.round(plot.bbox.width);
|
||||
const { data } = plot.ctx.getImageData(x, y - 1, width, 3);
|
||||
let hits = 0;
|
||||
for (let column = 0; column < width; column++) {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const i = (row * width + column) * 4;
|
||||
const close =
|
||||
data[i + 3] > 0 &&
|
||||
Math.abs(data[i] - r) < 60 &&
|
||||
Math.abs(data[i + 1] - g) < 60 &&
|
||||
Math.abs(data[i + 2] - b) < 60;
|
||||
if (close) {
|
||||
hits += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return width > 0 ? hits / width : 0;
|
||||
},
|
||||
{ value, color },
|
||||
);
|
||||
},
|
||||
async expectSeriesCount(count) {
|
||||
await expect
|
||||
.poll(async () => (await chart.state()).series.length)
|
||||
.toBe(count);
|
||||
},
|
||||
async hover() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async leave() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x - 20, box.y - 20);
|
||||
},
|
||||
async click() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async clickPoint(index) {
|
||||
await expect(host()).toBeVisible();
|
||||
const point = await host().evaluate((node, at) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
data: [number[], ...(number | null)[][]];
|
||||
scales: { x: { min?: number; max?: number } };
|
||||
series: { scale?: string }[];
|
||||
over: HTMLElement;
|
||||
valToPos: (value: number, scale: string) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const xs = plot.data[0];
|
||||
const { min = -Infinity, max = Infinity } = plot.scales.x;
|
||||
const visible = xs
|
||||
.map((x, i) => ({ x, i }))
|
||||
.filter(({ x }) => x >= min && x <= max);
|
||||
const i = at ?? visible[Math.floor(visible.length / 2)]?.i ?? 0;
|
||||
const y = plot.data[1]?.[i];
|
||||
if (y == null) {
|
||||
throw new Error(`no value at index ${i} of the first series`);
|
||||
}
|
||||
const over = plot.over.getBoundingClientRect();
|
||||
return {
|
||||
x: over.left + plot.valToPos(xs[i], 'x'),
|
||||
y: over.top + plot.valToPos(y, plot.series[1].scale ?? 'y'),
|
||||
};
|
||||
}, index);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
},
|
||||
async drag(fromFraction, toFraction) {
|
||||
const box = await boxOf(plotArea(), 'the plot area');
|
||||
await dragHorizontally(page, box, fromFraction, toFraction);
|
||||
},
|
||||
};
|
||||
return chart;
|
||||
}
|
||||
57
tests/e2e/drivers/panels/dom.ts
generated
57
tests/e2e/drivers/panels/dom.ts
generated
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export async function boxOf(
|
||||
locator: Locator,
|
||||
description: string,
|
||||
): Promise<Box> {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `expected a bounding box for ${description}`).not.toBeNull();
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
/** The extra pre-mousedown move gives uPlot a cursor anchor to drag from. */
|
||||
export async function dragHorizontally(
|
||||
page: Page,
|
||||
box: Box,
|
||||
fromFraction: number,
|
||||
toFraction: number,
|
||||
): Promise<void> {
|
||||
const y = box.y + box.height / 2;
|
||||
const startX = box.x + box.width * fromFraction;
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * toFraction, y, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function dragBy(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
steps: number,
|
||||
): Promise<void> {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** A visible option of whichever antd Select is open. */
|
||||
export function openSelectOption(page: Page, label: string): Locator {
|
||||
return page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText(label, { exact: true });
|
||||
}
|
||||
58
tests/e2e/drivers/panels/grid.ts
generated
58
tests/e2e/drivers/panels/grid.ts
generated
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragBy } from './dom';
|
||||
|
||||
/**
|
||||
* react-grid-layout exposes no testids; `.panel-drag-handle` and
|
||||
* `.react-resizable-handle` are the class contract it is configured with
|
||||
* (draggableHandle / draggableCancel in SectionGrid).
|
||||
*/
|
||||
export interface PanelGrid {
|
||||
/** The grid item's rendered size in px. */
|
||||
size(): Promise<{ width: number; height: number }>;
|
||||
/** Drags RGL's south-east grip by the given offset. */
|
||||
resize(dx: number, dy: number): Promise<void>;
|
||||
/** Drags the header handle across its own width. */
|
||||
dragByHeader(): Promise<void>;
|
||||
}
|
||||
|
||||
export function gridAt(page: Page, root: () => Locator): PanelGrid {
|
||||
// RGL appends the resize grip to its grid item, a sibling of the panel root.
|
||||
const gridItem = (): Locator =>
|
||||
root().locator('xpath=ancestor::div[contains(@class,"react-grid-item")][1]');
|
||||
return {
|
||||
async size() {
|
||||
const { width, height } = await boxOf(gridItem(), 'the grid item');
|
||||
return { width, height };
|
||||
},
|
||||
async resize(dx, dy) {
|
||||
const grip = await boxOf(
|
||||
gridItem().locator('.react-resizable-handle'),
|
||||
'the resize handle',
|
||||
);
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: grip.x + grip.width / 2, y: grip.y + grip.height / 2 },
|
||||
{ x: grip.x + dx, y: grip.y + dy },
|
||||
12,
|
||||
);
|
||||
},
|
||||
async dragByHeader() {
|
||||
const bar = await boxOf(
|
||||
root().locator('.panel-drag-handle').first(),
|
||||
'the drag handle',
|
||||
);
|
||||
const y = bar.y + bar.height / 2;
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: bar.x + bar.width / 4, y },
|
||||
{ x: bar.x + bar.width, y },
|
||||
15,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
tests/e2e/drivers/panels/heatmap.ts
generated
34
tests/e2e/drivers/panels/heatmap.ts
generated
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface HeatmapPanel {
|
||||
/** The count colour bar under the grid: caption, min label, ramp, max label. */
|
||||
colorBar(): Locator;
|
||||
/** Marks the hovered cell's count on the bar. */
|
||||
colorBarMarker(): Locator;
|
||||
}
|
||||
|
||||
export interface HeatmapTooltip {
|
||||
root(): Locator;
|
||||
bucket(): Locator;
|
||||
count(): Locator;
|
||||
}
|
||||
|
||||
export function heatmapAt(root: () => Locator): HeatmapPanel {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
colorBar: () => byTestId('color-bar'),
|
||||
colorBarMarker: () => byTestId('color-bar-marker'),
|
||||
};
|
||||
}
|
||||
|
||||
export function heatmapTooltipAt(page: Page): HeatmapTooltip {
|
||||
return {
|
||||
root: () => page.getByTestId('heatmap-tooltip'),
|
||||
bucket: () => page.getByTestId('heatmap-tooltip-bucket'),
|
||||
count: () => page.getByTestId('heatmap-tooltip-count'),
|
||||
};
|
||||
}
|
||||
52
tests/e2e/drivers/panels/legend.ts
generated
52
tests/e2e/drivers/panels/legend.ts
generated
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
export interface Legend {
|
||||
item(label: string): Locator;
|
||||
items(): Locator;
|
||||
/**
|
||||
* Clicks the row: with every series shown it solos this one, with only this
|
||||
* one shown it restores all, otherwise it toggles this one.
|
||||
*/
|
||||
solo(label: string): Promise<void>;
|
||||
/** The marker toggles just that series; disabled on the last one shown. */
|
||||
toggle(label: string): Promise<void>;
|
||||
/** The row's hover-revealed "Only" / "All" button. */
|
||||
scope(label: string): Locator;
|
||||
/** Rows are `role="switch"`; `aria-checked` carries the shown state. */
|
||||
expectShown(label: string, shown: boolean): Promise<void>;
|
||||
/** Rendered for a right legend, and a bottom one once its rows overflow. */
|
||||
search(): Locator;
|
||||
/** "Showing N of M series", rendered with the search box. */
|
||||
status(): Locator;
|
||||
}
|
||||
|
||||
export function legendAt(root: () => Locator): Legend {
|
||||
const legendItem = (label: string): Locator =>
|
||||
root().locator('[data-legend-item-id]').filter({ hasText: label });
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
item: legendItem,
|
||||
items: () => root().locator('[data-legend-item-id]'),
|
||||
async solo(label) {
|
||||
// The label, not the row centre: the scope button overlays its tail.
|
||||
await legendItem(label).getByText(label).click();
|
||||
},
|
||||
async toggle(label) {
|
||||
await legendItem(label).locator('[data-is-legend-marker]').click();
|
||||
},
|
||||
scope: (label) => legendItem(label).locator('[data-testid^="legend-scope-"]'),
|
||||
async expectShown(label, shown) {
|
||||
await expect(legendItem(label)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
String(shown),
|
||||
);
|
||||
},
|
||||
search: () => byTestId('legend-search-input'),
|
||||
status: () => byTestId('legend-status'),
|
||||
};
|
||||
}
|
||||
27
tests/e2e/drivers/panels/markdown.ts
generated
27
tests/e2e/drivers/panels/markdown.ts
generated
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** A rendered Text panel body. */
|
||||
export interface Markdown {
|
||||
root(): Locator;
|
||||
heading(name: string): Locator;
|
||||
listItems(): Locator;
|
||||
link(name: string): Locator;
|
||||
codeBlock(): Locator;
|
||||
/** "Nothing written yet", for a blank body. */
|
||||
empty(): Locator;
|
||||
}
|
||||
|
||||
export function markdownAt(root: () => Locator): Markdown {
|
||||
return {
|
||||
root,
|
||||
heading: (name) => root().getByRole('heading', { name }),
|
||||
listItems: () => root().getByRole('listitem'),
|
||||
link: (name) => root().getByRole('link', { name }),
|
||||
codeBlock: () => root().locator('pre code'),
|
||||
empty: () => root().getByTestId('text-panel-empty'),
|
||||
};
|
||||
}
|
||||
22
tests/e2e/drivers/panels/pie.ts
generated
22
tests/e2e/drivers/panels/pie.ts
generated
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** The donut @visx draws: one `<path>` per visible slice, the total in the hole. */
|
||||
export interface PiePanel {
|
||||
arcs(): Locator;
|
||||
/** The total of the visible slices, centred in the donut hole. */
|
||||
total(): Locator;
|
||||
}
|
||||
|
||||
export function pieAt(root: () => Locator): PiePanel {
|
||||
// The legend's icons are SVGs too; the donut is the first one.
|
||||
const chart = (): Locator =>
|
||||
root().getByTestId('pie-chart').locator('svg').first();
|
||||
return {
|
||||
arcs: () => chart().locator('g > path'),
|
||||
total: () => chart().locator('text[text-anchor="middle"]'),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user