Compare commits

..

6 Commits

144 changed files with 5860 additions and 11687 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -1,5 +0,0 @@
.waitingMessage {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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`);
});
});

View File

@@ -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();
});
});

View File

@@ -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;
},
};
};

View File

@@ -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);
}
}
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -21,6 +21,10 @@
composes: mono from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}
.fullWidth {
width: 100%;
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -1,7 +0,0 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fieldError {
composes: fieldError from './shared.module.scss';
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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%;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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',
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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]);
}

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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'));

View File

@@ -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');
});
});

View File

@@ -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,
},
},
};

View File

@@ -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}`);
}
};

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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: {

View File

@@ -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[];

View File

@@ -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 };
};

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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 || [],
};

View File

@@ -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,
};
}

View File

@@ -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();
},
},
},
);
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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);
});
});

View 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);
});
});

View 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) };
}

View 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);

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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}>

View File

@@ -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;

View File

@@ -132,7 +132,6 @@ export function useResizableColumns<T>({
onHeaderCell: (): ResizableHeaderProps => ({
width,
onResize: key && width ? handleResize(key) : undefined,
columnKey: key,
}),
} as Column<T>;
}),

View File

@@ -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

View File

@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
timeout: 10_000,
}),
);
await screen.findAllByTestId(/^list-columns-suggestion-/);
await screen.findAllByTestId('list-columns-suggestion');
},
};

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,
}),
},
},
});

View File

@@ -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,

View File

@@ -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');
});
});

View File

@@ -264,7 +264,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
sqlmigration.NewAddGCPConnectionMethodFactory(),
)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>) =>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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, '\\$&');
}

View File

@@ -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;
}

View File

@@ -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'),
})),
);
},
};
}

View File

@@ -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}`),
};
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 });
}

View File

@@ -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,
);
},
};
}

View File

@@ -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'),
};
}

View File

@@ -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'),
};
}

View File

@@ -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'),
};
}

View File

@@ -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