mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-04 15:31:08 +00:00
Compare commits
10 Commits
feat/react
...
fix/panel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e18463b0c | ||
|
|
2e419f9587 | ||
|
|
9d465d54f5 | ||
|
|
7d95fa7b25 | ||
|
|
cd1068e1c3 | ||
|
|
dca68d89bc | ||
|
|
1be806c58d | ||
|
|
8569702161 | ||
|
|
1b8451650a | ||
|
|
a5bfebbc29 |
@@ -2205,10 +2205,10 @@ components:
|
||||
type: object
|
||||
CloudintegrationtypesAWSConnectionArtifact:
|
||||
properties:
|
||||
connectionUrl:
|
||||
cliCommand:
|
||||
type: string
|
||||
required:
|
||||
- connectionUrl
|
||||
- cliCommand
|
||||
type: object
|
||||
CloudintegrationtypesAWSIntegrationConfig:
|
||||
properties:
|
||||
@@ -2364,11 +2364,8 @@ components:
|
||||
properties:
|
||||
cliCommand:
|
||||
type: string
|
||||
cloudPowerShellCommand:
|
||||
type: string
|
||||
required:
|
||||
- cliCommand
|
||||
- cloudPowerShellCommand
|
||||
type: object
|
||||
CloudintegrationtypesAzureIntegrationConfig:
|
||||
properties:
|
||||
|
||||
@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
|
||||
// Over this:
|
||||
type ConfigAdapter struct { ... }
|
||||
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
|
||||
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
func (adapter *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
```
|
||||
|
||||
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
|
||||
@@ -66,10 +66,10 @@ A method on a structured type should not strip meaning from the structure it bel
|
||||
|
||||
```go
|
||||
// Problematic: callers cannot distinguish how items were related.
|
||||
func (o *Order) AllLineItems() []LineItem { ... }
|
||||
func (order *Order) AllLineItems() []LineItem { ... }
|
||||
|
||||
// Better: the validation logic operates on the full structure.
|
||||
func ValidateOrder(o *Order) error { ... }
|
||||
func ValidateOrder(order *Order) error { ... }
|
||||
```
|
||||
|
||||
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.
|
||||
|
||||
@@ -74,7 +74,7 @@ The `*Visitor` passed in provides everything needed to build predicates. Use the
|
||||
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
|
||||
|
||||
```go
|
||||
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
func (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
switch key {
|
||||
case "created_by":
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)
|
||||
|
||||
@@ -82,7 +82,7 @@ In the module implementation, implement the new method. A typical implementation
|
||||
- Uses the `render` package to write responses or errors
|
||||
|
||||
```go
|
||||
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
func (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
// Extract authentication and organization context from req.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
@@ -98,7 +98,7 @@ func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
// Call module functions
|
||||
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -59,7 +59,8 @@ Follow these rules:
|
||||
|
||||
### Unexported symbols
|
||||
|
||||
- Struct receivers: one or two characters (`c`, `f`, `br`).
|
||||
- Struct receivers: the lowercase name of the role the type plays, the same on every method of the type (`provider`, `module`, `handler`, `store`, `middleware`, `migration`). Never one or two characters: `c`, `h`, `m` say nothing at the call site and collide with local variables.
|
||||
- When the receiver name would shadow an imported package, alias the import, not the receiver (`pkghandler "github.com/SigNoz/signoz/pkg/http/handler"` with a `handler` receiver).
|
||||
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
|
||||
|
||||
### Constants
|
||||
|
||||
@@ -70,16 +70,16 @@ The instrumentation SDK (`pkg/instrumentation/sdk.go`) is idle while running but
|
||||
```go
|
||||
// pkg/instrumentation/sdk.go
|
||||
|
||||
func (i *SDK) Start(ctx context.Context) error {
|
||||
<-i.startCh
|
||||
func (sdk *SDK) Start(ctx context.Context) error {
|
||||
<-sdk.startCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *SDK) Stop(ctx context.Context) error {
|
||||
close(i.startCh)
|
||||
func (sdk *SDK) Stop(ctx context.Context) error {
|
||||
close(sdk.startCh)
|
||||
return errors.Join(
|
||||
i.sdk.Shutdown(ctx),
|
||||
i.meterProviderShutdownFunc(ctx),
|
||||
sdk.sdk.Shutdown(ctx),
|
||||
sdk.meterProviderShutdownFunc(ctx),
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Start(ctx context.Context) error { ... }
|
||||
func (s *service) Stop(ctx context.Context) error { ... }
|
||||
func (service *service) Start(ctx context.Context) error { ... }
|
||||
func (service *service) Stop(ctx context.Context) error { ... }
|
||||
```
|
||||
|
||||
### Provider that is also a service
|
||||
|
||||
@@ -17,7 +17,7 @@ Two rules shape how the core type behaves:
|
||||
func NewGettableAuthDomainFromAuthDomain(d *AuthDomain, info *AuthNProviderInfo) *GettableAuthDomain
|
||||
|
||||
// Receiver form
|
||||
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
func (maintenance *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
```
|
||||
- **`X` can double as the storage row** when the DB shape would be identical. `Channel` embeds `bun.BaseModel` directly, and there is no `StorableChannel`. This is the preferred shape when it works.
|
||||
|
||||
@@ -175,10 +175,10 @@ The rules that make the envelope work:
|
||||
|
||||
```go
|
||||
// Domain invariants: every write path re-runs these.
|
||||
func (x *X) Validate() error { ... }
|
||||
func (entity *X) Validate() error { ... }
|
||||
|
||||
// Request-shape-only: checks that do not apply once the value is persisted.
|
||||
func (p *PostableX) Validate() error { ... }
|
||||
func (postable *PostableX) Validate() error { ... }
|
||||
```
|
||||
- **Type aliases, not wrappers**, when two shapes are identical. `type GettableChannels = []*Channel` is correct because it adds no semantics beyond the underlying type.
|
||||
- **Serialization tags** follow [handler.md](handler.md): `required:"true"` means the JSON key must be present, `nullable:"true"` is required on any slice or map that may serialize as `null`, and types with a fixed value set must implement `Enum() []any`.
|
||||
|
||||
@@ -2,8 +2,6 @@ package implcloudprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
|
||||
@@ -18,29 +16,14 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
|
||||
return &awscloudprovider{serviceDefinitions: defStore}, nil
|
||||
}
|
||||
|
||||
// TODO: move URL construction logic to cloudintegrationtypes and add unit tests for it.
|
||||
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
|
||||
u, _ := url.Parse(baseURL)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("region", req.Config.AWS.DeploymentRegion)
|
||||
u.Fragment = "/stacks/quickcreate"
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
q = u.Query()
|
||||
q.Set("stackName", cloudintegrationtypes.AgentCloudFormationBaseStackName.StringValue())
|
||||
q.Set("templateURL", fmt.Sprintf(cloudintegrationtypes.AgentCloudFormationTemplateS3Path.StringValue(), req.Config.AgentVersion))
|
||||
q.Set("param_SigNozIntegrationAgentVersion", req.Config.AgentVersion)
|
||||
q.Set("param_SigNozApiUrl", req.Credentials.SigNozAPIURL)
|
||||
q.Set("param_SigNozApiKey", req.Credentials.SigNozAPIKey)
|
||||
q.Set("param_SigNozAccountId", account.ID.StringValue())
|
||||
q.Set("param_IngestionUrl", req.Credentials.IngestionURL)
|
||||
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
|
||||
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
|
||||
AWS: connectionArtifact,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3936,7 +3936,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
connectionUrl: string;
|
||||
cliCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
|
||||
@@ -4146,10 +4146,6 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
* @type string
|
||||
*/
|
||||
cliCommand: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cloudPowerShellCommand: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import { RelativeDurationSuggestionOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
@@ -24,6 +24,7 @@ import dayjs from 'dayjs';
|
||||
import { Clock, PenLine, TriangleAlert } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { getCustomTimeRanges } from 'utils/customTimeRangeUtils';
|
||||
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
|
||||
import { TimeRangeValidationResult } from 'utils/timeUtils';
|
||||
|
||||
import CalendarContainer from './CalendarContainer';
|
||||
@@ -121,21 +122,14 @@ function CustomTimePickerPopoverContent({
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const url = new URLSearchParams(window.location.search);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const activeTimezoneOffset = timezone.offset;
|
||||
|
||||
let panelTypeFromURL = url.get(QueryParams.panelTypes);
|
||||
|
||||
try {
|
||||
panelTypeFromURL = JSON.parse(panelTypeFromURL as string);
|
||||
} catch {
|
||||
// fallback → leave as-is
|
||||
}
|
||||
const panelTypeFromURL = readPanelTypeFromUrl();
|
||||
|
||||
const isLogsListView =
|
||||
panelTypeFromURL !== 'table' && panelTypeFromURL !== 'graph'; // we do not select list view in the url
|
||||
panelTypeFromURL !== PANEL_TYPES.TABLE &&
|
||||
panelTypeFromURL !== PANEL_TYPES.TIME_SERIES; // we do not select list view in the url
|
||||
|
||||
const [dateRange, setDateRange] = useState<DateRange>(() =>
|
||||
getDateRange(minTime, maxTime, timezone.value),
|
||||
|
||||
@@ -18,6 +18,7 @@ function InputWithLabel({
|
||||
className,
|
||||
closeIcon,
|
||||
disabled,
|
||||
sanitize,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number | null;
|
||||
@@ -29,14 +30,17 @@ function InputWithLabel({
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Rewrites the typed value before it is shown and reported. */
|
||||
sanitize?: (value: string) => string;
|
||||
}): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
initialValue ? initialValue.toString() : '',
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
const value = sanitize ? sanitize(e.target.value) : e.target.value;
|
||||
setInputValue(value);
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -78,6 +82,7 @@ InputWithLabel.defaultProps = {
|
||||
initialValue: undefined,
|
||||
className: undefined,
|
||||
closeIcon: undefined,
|
||||
sanitize: undefined,
|
||||
};
|
||||
|
||||
export default InputWithLabel;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
import { parseLimit, sanitizeLimit } from '../../utils';
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
@@ -278,7 +279,7 @@ function QueryAddOns({
|
||||
|
||||
const handleChangeLimit = useCallback(
|
||||
(value: string) => {
|
||||
handleChangeQueryData('limit', Number(value) || null);
|
||||
handleChangeQueryData('limit', parseLimit(value));
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
@@ -374,6 +375,7 @@ function QueryAddOns({
|
||||
label="Limit"
|
||||
type="number"
|
||||
onChange={handleChangeLimit}
|
||||
sanitize={sanitizeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder={placeholderOf(QueryBuilderField.Limit)}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { QueryBuilderV2 } from '../../QueryBuilderV2';
|
||||
import { QueryBuilderV2Provider } from '../../QueryBuilderV2Context';
|
||||
import { QueryV2 } from '../QueryV2';
|
||||
|
||||
// Local mocks for domain-specific heavy child components
|
||||
jest.mock(
|
||||
@@ -249,11 +251,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
|
||||
describe('query actions menu', () => {
|
||||
const openQueryActions = async (): Promise<void> => {
|
||||
const trigger = document.querySelector(
|
||||
'.query-actions-container [aria-haspopup="menu"]',
|
||||
) as HTMLElement;
|
||||
expect(trigger).toBeInTheDocument();
|
||||
await userEvent.click(trigger);
|
||||
await userEvent.click(screen.getAllByTestId('query-actions')[0]);
|
||||
};
|
||||
|
||||
it('Clone calls cloneQuery with the query', async () => {
|
||||
@@ -261,7 +259,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
|
||||
await openQueryActions();
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Clone' }),
|
||||
await screen.findByTestId('query-actions-item-clone-query'),
|
||||
);
|
||||
|
||||
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
|
||||
@@ -275,41 +273,44 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
|
||||
await openQueryActions();
|
||||
await screen.findByRole('menuitem', { name: 'Clone' });
|
||||
await screen.findByTestId('query-actions-item-clone-query');
|
||||
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: 'Delete' }),
|
||||
screen.queryByTestId('query-actions-item-delete-query'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Delete with more than one query and deletes on click', async () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const [queryA] = currentQueryBase.builder.queryData;
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
...currentQueryBase.builder,
|
||||
queryData: [queryA, { ...queryA, queryName: 'B', legend: 'B' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const [queryA] = (baseQBContext.currentQuery as Query).builder.queryData;
|
||||
const handleDeleteQuery = jest.fn();
|
||||
mockedUseQueryOperations.mockReturnValue({
|
||||
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
|
||||
handleDeleteQuery,
|
||||
});
|
||||
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
|
||||
render(
|
||||
<QueryBuilderV2Provider>
|
||||
<QueryV2
|
||||
index={0}
|
||||
query={queryA}
|
||||
version="v4"
|
||||
isAvailableToDisable={false}
|
||||
isMultiQueryAllowed
|
||||
queriesCount={2}
|
||||
onSignalSourceChange={jest.fn()}
|
||||
signalSourceChangeEnabled={false}
|
||||
savePreviousQuery={false}
|
||||
/>
|
||||
</QueryBuilderV2Provider>,
|
||||
);
|
||||
|
||||
await openQueryActions();
|
||||
await userEvent.click(
|
||||
await screen.findByRole('menuitem', { name: 'Delete' }),
|
||||
await screen.findByTestId('query-actions-item-delete-query'),
|
||||
);
|
||||
|
||||
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
|
||||
@@ -169,8 +169,15 @@ describe('QueryAddOns', () => {
|
||||
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
|
||||
expect(input.value).toBe('5');
|
||||
|
||||
fireEvent.change(input, { target: { value: '10' } });
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
|
||||
const user = userEvent.setup();
|
||||
await user.clear(input);
|
||||
await user.type(input, '10');
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, '-5');
|
||||
expect(input.value).toBe('5');
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -12,10 +12,62 @@ import {
|
||||
convertExpressionToFilters,
|
||||
convertFiltersToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
formatValueForExpression,
|
||||
parseLimit,
|
||||
removeKeysFromExpression,
|
||||
sanitizeLimit,
|
||||
} from '../utils';
|
||||
|
||||
describe('convertHavingToExpression', () => {
|
||||
it.each([
|
||||
['COUNT()', 'count() > 5'],
|
||||
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
|
||||
['P99(duration_nano)', 'p99(duration_nano) > 5'],
|
||||
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
|
||||
['count()', 'count() > 5'],
|
||||
])('references %s by its lower-case function', (columnName, expected) => {
|
||||
expect(
|
||||
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
|
||||
).toStrictEqual({ expression: expected });
|
||||
});
|
||||
|
||||
it('joins several conditions and formats list values', () => {
|
||||
expect(
|
||||
convertHavingToExpression([
|
||||
{ columnName: 'COUNT()', op: '>', value: 5 },
|
||||
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
|
||||
]),
|
||||
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeLimit', () => {
|
||||
it.each([
|
||||
['10', '10'],
|
||||
['-10', '10'],
|
||||
['2.5', '25'],
|
||||
['1e3', '13'],
|
||||
['', ''],
|
||||
])('sanitizes %j to %j', (value, expected) => {
|
||||
expect(sanitizeLimit(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLimit', () => {
|
||||
it.each([
|
||||
['10', 10],
|
||||
['1', 1],
|
||||
['', null],
|
||||
['0', null],
|
||||
['-5', null],
|
||||
['2.5', null],
|
||||
['abc', null],
|
||||
])('parses %j as %j', (value, expected) => {
|
||||
expect(parseLimit(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertFiltersToExpression', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -840,6 +840,16 @@ export const appendAndClause = (
|
||||
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
|
||||
};
|
||||
|
||||
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
|
||||
export const sanitizeLimit = (value: string): string =>
|
||||
value.replace(/\D/g, '');
|
||||
|
||||
/** A query or formula limit: a positive integer, otherwise none. */
|
||||
export const parseLimit = (value: string): number | null => {
|
||||
const limit = Number(value);
|
||||
return Number.isInteger(limit) && limit > 0 ? limit : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
@@ -871,7 +881,14 @@ export const convertHavingToExpression = (
|
||||
formattedValue = String(value);
|
||||
}
|
||||
|
||||
return `${columnName} ${op} ${formattedValue}`;
|
||||
// The old having filter upper-cased the function (`COUNT()`); v5 only
|
||||
// resolves the aggregation's own lower-case expression.
|
||||
const reference = columnName.replace(
|
||||
/^\s*([A-Za-z_]\w*)\(/,
|
||||
(_, fn: string) => `${fn.toLowerCase()}(`,
|
||||
);
|
||||
|
||||
return `${reference} ${op} ${formattedValue}`;
|
||||
})
|
||||
.filter((expression) => expression !== ''); // Remove empty expressions
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from 'api/ai-assistant/chat';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import {
|
||||
ArchiveRestore,
|
||||
@@ -47,6 +48,10 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
@@ -333,6 +338,11 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Read before toUrlCompositeQuery, which drops the requestType envelope.
|
||||
const panelType = getPanelTypeForRequestType(
|
||||
requestTypeFromActionQuery(action.query),
|
||||
action.signal,
|
||||
);
|
||||
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] normalized', normalized);
|
||||
@@ -342,7 +352,9 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
normalized.builder.queryData.forEach((q, i) => {
|
||||
deps.handleSetQueryData(i, q);
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(normalized);
|
||||
deps.redirectWithQueryBuilderData(normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const base = explorerRouteForSignal(action.signal);
|
||||
@@ -353,8 +365,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → navigate', base);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
const url = buildExplorerNavigationUrl(base, normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
});
|
||||
navigate(url);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
@@ -613,6 +627,7 @@ export default function ActionsSection({
|
||||
disabled={isSuccess}
|
||||
loading={isLoading}
|
||||
aria-label={action.label}
|
||||
testId={`ai-action-chip-${action.kind}`}
|
||||
prefix={icon}
|
||||
>
|
||||
<span className={styles.chipLabel}>{action.label}</span>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { undoExecution } from 'api/ai-assistant/chat';
|
||||
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getCurrentLocation } from 'lib/router/navigation';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import ActionsSection from '../ActionsSection';
|
||||
|
||||
@@ -28,6 +37,37 @@ function renderUndoChip(): HTMLElement {
|
||||
return screen.getByRole('button', { name: 'Undo change' });
|
||||
}
|
||||
|
||||
// Labels differ per test: auto-applied filters are deduped module-wide by label.
|
||||
function countByServiceAction(label: string): MessageActionDTO {
|
||||
return {
|
||||
kind: MessageActionKindDTO.apply_filter,
|
||||
label,
|
||||
signal: ApplyFilterSignalDTO.logs,
|
||||
query: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function currentPanelTypesParam(): string | null {
|
||||
return new URLSearchParams(getCurrentLocation().search).get(
|
||||
QueryParams.panelTypes,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ActionsSection rollback chip', () => {
|
||||
beforeEach(() => {
|
||||
mockUndo.mockReset();
|
||||
@@ -81,3 +121,52 @@ describe('ActionsSection rollback chip', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionsSection apply_filter', () => {
|
||||
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[countByServiceAction('Open in Logs Explorer')]}
|
||||
messageId="message-1"
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: ROUTES.HOME },
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
|
||||
);
|
||||
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
|
||||
});
|
||||
|
||||
it('switches the open explorer to the Table view when applied on it', async () => {
|
||||
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
|
||||
QueryBuilderContextType['redirectWithQueryBuilderData']
|
||||
>;
|
||||
const handleSetQueryData = jest.fn() as jest.MockedFunction<
|
||||
QueryBuilderContextType['handleSetQueryData']
|
||||
>;
|
||||
render(
|
||||
<ActionsSection
|
||||
actions={[countByServiceAction('Apply on Logs Explorer')]}
|
||||
messageId="message-2"
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: ROUTES.LOGS_EXPLORER,
|
||||
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ builder: expect.anything() }),
|
||||
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from '../getPanelTypeForRequestType';
|
||||
|
||||
describe('getPanelTypeForRequestType', () => {
|
||||
it.each([
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.TABLE,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.raw,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
ApplyFilterSignalDTO.traces,
|
||||
PANEL_TYPES.TRACE,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
ApplyFilterSignalDTO.logs,
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
[
|
||||
Querybuildertypesv5RequestTypeDTO.trace,
|
||||
ApplyFilterSignalDTO.metrics,
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
|
||||
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
Querybuildertypesv5RequestTypeDTO.raw_stream,
|
||||
Querybuildertypesv5RequestTypeDTO.heatmap,
|
||||
'distribution',
|
||||
'nonsense',
|
||||
'toString',
|
||||
'constructor',
|
||||
42,
|
||||
{},
|
||||
])(
|
||||
'falls back to the List view for unmapped or invalid requestType (%p)',
|
||||
(value) => {
|
||||
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('requestTypeFromActionQuery', () => {
|
||||
it('reads the requestType envelope field and returns undefined when absent', () => {
|
||||
expect(
|
||||
requestTypeFromActionQuery({
|
||||
requestType: 'scalar',
|
||||
schemaVersion: 'v5',
|
||||
compositeQuery: { queries: [] },
|
||||
}),
|
||||
).toBe('scalar');
|
||||
expect(requestTypeFromActionQuery({})).toBeUndefined();
|
||||
expect(requestTypeFromActionQuery(null)).toBeUndefined();
|
||||
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps a grouped count() payload to the Table view', () => {
|
||||
const query = {
|
||||
requestType: 'scalar',
|
||||
schemaVersion: 'v5',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
signal: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
getPanelTypeForRequestType(
|
||||
requestTypeFromActionQuery(query),
|
||||
ApplyFilterSignalDTO.logs,
|
||||
),
|
||||
).toBe(PANEL_TYPES.TABLE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
type MessageActionDTOQuery,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export function getPanelTypeForRequestType(
|
||||
requestType: unknown,
|
||||
signal: ApplyFilterSignalDTO,
|
||||
): PANEL_TYPES {
|
||||
switch (requestType) {
|
||||
case Querybuildertypesv5RequestTypeDTO.scalar:
|
||||
return PANEL_TYPES.TABLE;
|
||||
case Querybuildertypesv5RequestTypeDTO.time_series:
|
||||
return PANEL_TYPES.TIME_SERIES;
|
||||
case Querybuildertypesv5RequestTypeDTO.trace:
|
||||
return signal === ApplyFilterSignalDTO.traces
|
||||
? PANEL_TYPES.TRACE
|
||||
: PANEL_TYPES.LIST;
|
||||
default:
|
||||
return PANEL_TYPES.LIST;
|
||||
}
|
||||
}
|
||||
|
||||
export function requestTypeFromActionQuery(
|
||||
query: MessageActionDTOQuery | undefined,
|
||||
): unknown {
|
||||
return query?.requestType;
|
||||
}
|
||||
@@ -140,11 +140,6 @@ export default function AlertRules({
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
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 {
|
||||
@@ -1,27 +1,28 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import CodeBlock from 'components/CodeBlock/CodeBlock';
|
||||
|
||||
import styles from './AgentSetupCommand.module.scss';
|
||||
|
||||
interface AgentSetupCommandProps {
|
||||
deploymentProjectId: string;
|
||||
description: ReactNode;
|
||||
command: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function AgentSetupCommand({
|
||||
deploymentProjectId,
|
||||
description,
|
||||
command,
|
||||
testId,
|
||||
}: AgentSetupCommandProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commands} data-testid="gcp-agent-command">
|
||||
<div className={styles.commands} data-testid={testId}>
|
||||
<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.
|
||||
{description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CodeBlock language="bash" code={command} className={styles.command} />
|
||||
@@ -15,14 +15,7 @@ function AlertMessage({
|
||||
<Callout
|
||||
title={
|
||||
<div className="cloud-account-setup-form__alert-message">
|
||||
<Spin
|
||||
indicator={
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className="anticon anticon-loading anticon-spin ant-spin-dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Spin indicator={<LoaderCircle size={14} className="animate-spin" />} />
|
||||
Waiting for connection, retrying in{' '}
|
||||
<span className="retry-time">10</span> secs...
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -144,52 +151,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-account-setup-form__code-block-tabs {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
margin-bottom: 12px;
|
||||
|
||||
&-title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&-description {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
|
||||
[role='tablist'] {
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
[role='tabpanel'] {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
[data-slot='tabs-item'] {
|
||||
padding: 4px 24px !important;
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cloud-account-setup-accordion-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { useIntegrationModal } from 'hooks/integration/aws/useIntegrationModal';
|
||||
import { SquareArrowOutUpRight } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
ActiveViewEnum,
|
||||
IntegrationModalProps,
|
||||
ModalStateEnum,
|
||||
} from '../../../HeroSection/types';
|
||||
import CloseSetupConfirm from '../../CloseSetupConfirm/CloseSetupConfirm';
|
||||
import { useCloseSetupConfirm } from '../../CloseSetupConfirm/useCloseSetupConfirm';
|
||||
import { RegionForm } from '../RegionForm/RegionForm';
|
||||
|
||||
import './CloudAccountSetupModal.style.scss';
|
||||
@@ -24,22 +24,24 @@ function CloudAccountSetupModal({
|
||||
activeView,
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
isGeneratingUrl,
|
||||
isGeneratingCommand,
|
||||
connectionCommand,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
handleRegionSelect,
|
||||
handleSubmit,
|
||||
handleClose,
|
||||
setActiveView,
|
||||
accountId,
|
||||
handleRegionChange,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
checkConnectionAgain,
|
||||
} = useIntegrationModal({ onClose });
|
||||
|
||||
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
|
||||
{ shouldConfirm: Boolean(connectionCommand), onClose: handleClose },
|
||||
);
|
||||
|
||||
const renderContent = useCallback(() => {
|
||||
return (
|
||||
<div className="cloud-account-setup-modal__content">
|
||||
@@ -50,15 +52,12 @@ function CloudAccountSetupModal({
|
||||
includeAllRegions={includeAllRegions}
|
||||
onRegionSelect={handleRegionSelect}
|
||||
onSubmit={handleSubmit}
|
||||
accountId={accountId}
|
||||
connectionCommand={connectionCommand}
|
||||
handleRegionChange={handleRegionChange}
|
||||
connectionParams={connectionParams}
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
setIncludeAllRegions={setIncludeAllRegions}
|
||||
onConnectionSuccess={handleConnectionSuccess}
|
||||
onConnectionTimeout={handleConnectionTimeout}
|
||||
onConnectionError={handleConnectionError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -69,38 +68,50 @@ function CloudAccountSetupModal({
|
||||
includeAllRegions,
|
||||
handleRegionSelect,
|
||||
handleSubmit,
|
||||
accountId,
|
||||
connectionCommand,
|
||||
handleRegionChange,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
]);
|
||||
|
||||
const footer = (
|
||||
<div className="cloud-account-setup-modal__footer">
|
||||
<Button
|
||||
disabledTooltip={
|
||||
selectedRegions.length === 0
|
||||
? 'Select at least one region'
|
||||
: 'Waiting for the account to connect'
|
||||
}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />}
|
||||
onClick={handleSubmit}
|
||||
testId="aws-launch-cfn-btn"
|
||||
disabled={
|
||||
selectedRegions.length === 0 || modalState === ModalStateEnum.WAITING
|
||||
}
|
||||
loading={isLoading || isGeneratingUrl}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={requestClose}
|
||||
testId="aws-cancel-btn"
|
||||
>
|
||||
Launch Cloud Formation Template
|
||||
Cancel
|
||||
</Button>
|
||||
{!connectionCommand && (
|
||||
<Button
|
||||
disabledTooltip="Select at least one region"
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={selectedRegions.length === 0}
|
||||
loading={isLoading || isGeneratingCommand}
|
||||
testId="aws-generate-command-btn"
|
||||
>
|
||||
Generate Command
|
||||
</Button>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && connectionCommand && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={checkConnectionAgain}
|
||||
testId="aws-check-connection-btn"
|
||||
>
|
||||
Check Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -113,22 +124,17 @@ function CloudAccountSetupModal({
|
||||
const viewConfigs = {
|
||||
[ActiveViewEnum.FORM]: {
|
||||
title: 'Add AWS Account',
|
||||
okText: (
|
||||
<div className="cloud-account-setup-form__submit-button-content">
|
||||
Launch Cloud Formation Template{' '}
|
||||
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
|
||||
</div>
|
||||
),
|
||||
okText: 'Generate Command',
|
||||
onOk: handleSubmit,
|
||||
disabled:
|
||||
isLoading || isGeneratingUrl || modalState === ModalStateEnum.WAITING,
|
||||
isLoading || isGeneratingCommand || modalState === ModalStateEnum.WAITING,
|
||||
cancelButtonProps: { style: { display: 'none' } },
|
||||
},
|
||||
[ActiveViewEnum.SELECT_REGIONS]: {
|
||||
title: 'Which regions do you want to monitor?',
|
||||
okText: `Confirm Selection (${getSelectedRegionsCount()})`,
|
||||
onOk: (): void => setActiveView(ActiveViewEnum.FORM),
|
||||
isLoading: isLoading || isGeneratingUrl,
|
||||
isLoading: isLoading || isGeneratingCommand,
|
||||
cancelButtonProps: { style: { display: 'block' } },
|
||||
disabled: false,
|
||||
},
|
||||
@@ -140,7 +146,7 @@ function CloudAccountSetupModal({
|
||||
handleSubmit,
|
||||
getSelectedRegionsCount,
|
||||
isLoading,
|
||||
isGeneratingUrl,
|
||||
isGeneratingCommand,
|
||||
activeView,
|
||||
setActiveView,
|
||||
]);
|
||||
@@ -149,23 +155,32 @@ function CloudAccountSetupModal({
|
||||
|
||||
const handleDrawerOpenChange = (open: boolean): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
requestClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className="cloud-account-setup-modal"
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title={modalConfig.title}
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
{renderContent()}
|
||||
</DrawerWrapper>
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className="cloud-account-setup-modal"
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title={modalConfig.title}
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
{renderContent()}
|
||||
</DrawerWrapper>
|
||||
<CloseSetupConfirm
|
||||
providerName="AWS"
|
||||
testIdPrefix="aws"
|
||||
open={isConfirmOpen}
|
||||
onOpenChange={setIsConfirmOpen}
|
||||
onConfirm={handleClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@ import { RegionSelector } from './RegionForm/RegionSelector';
|
||||
function RegionDeploymentSection({
|
||||
regions,
|
||||
handleRegionChange,
|
||||
isFormDisabled,
|
||||
}: {
|
||||
regions: Region[];
|
||||
handleRegionChange: (value: string) => void;
|
||||
isFormDisabled: boolean;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="cloud-account-setup-form__form-group">
|
||||
@@ -36,7 +34,6 @@ function RegionDeploymentSection({
|
||||
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
|
||||
className="cloud-account-setup-form__select integrations-select"
|
||||
onChange={handleRegionChange}
|
||||
disabled={isFormDisabled}
|
||||
getPopupContainer={popupContainer}
|
||||
>
|
||||
{regions.flatMap((region) =>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useRef } from 'react';
|
||||
import { Form } from 'antd';
|
||||
import { useGetAccount } from 'api/generated/services/cloudintegration';
|
||||
import cx from 'classnames';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import {
|
||||
ModalStateEnum,
|
||||
RegionFormProps,
|
||||
} from 'container/Integrations/HeroSection/types';
|
||||
import { RegionFormProps } from 'container/Integrations/HeroSection/types';
|
||||
import { regions } from 'utils/regions';
|
||||
|
||||
import AgentSetupCommand from '../../AgentSetupCommand/AgentSetupCommand';
|
||||
import AlertMessage from '../../AlertMessage';
|
||||
import {
|
||||
ComplianceNote,
|
||||
@@ -22,56 +16,13 @@ export function RegionForm({
|
||||
modalState,
|
||||
selectedRegions,
|
||||
onSubmit,
|
||||
accountId,
|
||||
connectionCommand,
|
||||
handleRegionChange,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
onConnectionSuccess,
|
||||
onConnectionTimeout,
|
||||
onConnectionError,
|
||||
}: RegionFormProps): JSX.Element {
|
||||
const startTimeRef = useRef(Date.now());
|
||||
const refetchInterval = 10 * 1000;
|
||||
const errorTimeout = 10 * 60 * 1000;
|
||||
|
||||
const { isLoading: isAccountStatusLoading } = useGetAccount(
|
||||
{
|
||||
cloudProvider: INTEGRATION_TYPES.AWS,
|
||||
id: accountId ?? '',
|
||||
},
|
||||
{
|
||||
query: {
|
||||
refetchInterval,
|
||||
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
|
||||
onSuccess: (response) => {
|
||||
const isConnected =
|
||||
Boolean(response.data.providerAccountId) &&
|
||||
response.data.removedAt === null;
|
||||
|
||||
if (isConnected) {
|
||||
const cloudAccountId =
|
||||
response.data.providerAccountId ?? response.data.id;
|
||||
|
||||
onConnectionSuccess({
|
||||
cloudAccountId,
|
||||
status: response.data.agentReport,
|
||||
});
|
||||
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
|
||||
onConnectionTimeout({ id: accountId });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onConnectionError();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isFormDisabled =
|
||||
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
@@ -79,28 +30,30 @@ export function RegionForm({
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
>
|
||||
<div
|
||||
className={cx(`cloud-account-setup-form__content`, {
|
||||
disabled: isFormDisabled,
|
||||
})}
|
||||
>
|
||||
<RegionDeploymentSection
|
||||
regions={regions}
|
||||
handleRegionChange={handleRegionChange}
|
||||
isFormDisabled={isFormDisabled}
|
||||
{connectionCommand ? (
|
||||
<AgentSetupCommand
|
||||
description="Run this command in AWS CloudShell, or in a terminal where the AWS CLI is signed in to the account you want to monitor. It contains your SigNoz keys, so do not share it."
|
||||
command={connectionCommand}
|
||||
testId="aws-agent-command"
|
||||
/>
|
||||
<MonitoringRegionsSection
|
||||
selectedRegions={selectedRegions}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
setIncludeAllRegions={setIncludeAllRegions}
|
||||
/>
|
||||
<ComplianceNote />
|
||||
<RenderConnectionFields
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
isFormDisabled={isFormDisabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cloud-account-setup-form__content">
|
||||
<RegionDeploymentSection
|
||||
regions={regions}
|
||||
handleRegionChange={handleRegionChange}
|
||||
/>
|
||||
<MonitoringRegionsSection
|
||||
selectedRegions={selectedRegions}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
setIncludeAllRegions={setIncludeAllRegions}
|
||||
/>
|
||||
<ComplianceNote />
|
||||
<RenderConnectionFields
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cloud-account-setup-form__alert">
|
||||
<AlertMessage modalState={modalState} />
|
||||
|
||||
@@ -5,11 +5,9 @@ import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigN
|
||||
function RenderConnectionFields({
|
||||
isConnectionParamsLoading,
|
||||
connectionParams,
|
||||
isFormDisabled,
|
||||
}: {
|
||||
isConnectionParamsLoading?: boolean;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO | null;
|
||||
isFormDisabled?: boolean;
|
||||
}): JSX.Element | null {
|
||||
if (
|
||||
isConnectionParamsLoading ||
|
||||
@@ -29,7 +27,7 @@ function RenderConnectionFields({
|
||||
label="Ingestion URL"
|
||||
rules={[{ required: true, message: 'Please enter ingestion URL' }]}
|
||||
>
|
||||
<Input placeholder="Enter ingestion URL" disabled={isFormDisabled} />
|
||||
<Input placeholder="Enter ingestion URL" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!connectionParams?.ingestionKey && (
|
||||
@@ -38,7 +36,7 @@ function RenderConnectionFields({
|
||||
label="Ingestion Key"
|
||||
rules={[{ required: true, message: 'Please enter ingestion key' }]}
|
||||
>
|
||||
<Input placeholder="Enter ingestion key" disabled={isFormDisabled} />
|
||||
<Input placeholder="Enter ingestion key" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!connectionParams?.sigNozApiUrl && (
|
||||
@@ -47,7 +45,7 @@ function RenderConnectionFields({
|
||||
label="SigNoz API URL"
|
||||
rules={[{ required: true, message: 'Please enter SigNoz API URL' }]}
|
||||
>
|
||||
<Input placeholder="Enter SigNoz API URL" disabled={isFormDisabled} />
|
||||
<Input placeholder="Enter SigNoz API URL" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{!connectionParams?.sigNozApiKey && (
|
||||
@@ -56,7 +54,7 @@ function RenderConnectionFields({
|
||||
label="SigNoz API KEY"
|
||||
rules={[{ required: true, message: 'Please enter SigNoz API Key' }]}
|
||||
>
|
||||
<Input placeholder="Enter SigNoz API Key" disabled={isFormDisabled} />
|
||||
<Input placeholder="Enter SigNoz API Key" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
@@ -65,7 +63,6 @@ function RenderConnectionFields({
|
||||
|
||||
RenderConnectionFields.defaultProps = {
|
||||
connectionParams: null,
|
||||
isFormDisabled: false,
|
||||
isConnectionParamsLoading: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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 type logEvent from '../../../../../api/common/logEvent';
|
||||
|
||||
import CloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
|
||||
|
||||
// `useIntegrationModal` 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() as jest.MockedFunction<typeof logEvent>,
|
||||
}));
|
||||
|
||||
// The form flow drives antd selects and typing, which outruns the 5s default on CI.
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
type OnClose = ComponentProps<typeof CloudAccountSetupModal>['onClose'];
|
||||
|
||||
const mockOnClose = (): jest.MockedFunction<OnClose> =>
|
||||
jest.fn() as jest.MockedFunction<OnClose>;
|
||||
|
||||
const AWS_CREDENTIALS_URL =
|
||||
'http://localhost/api/v1/cloud_integrations/aws/credentials';
|
||||
const AWS_ACCOUNTS_URL =
|
||||
'http://localhost/api/v1/cloud_integrations/aws/accounts';
|
||||
const ACCOUNT_ID = 'ci-aws-1234';
|
||||
|
||||
const credentials = {
|
||||
sigNozApiUrl: 'https://signoz.example.com',
|
||||
sigNozApiKey: 'signoz-api-key',
|
||||
ingestionUrl: 'https://ingest.example.com',
|
||||
ingestionKey: 'ingestion-key',
|
||||
};
|
||||
|
||||
const cliCommand = `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration aws cloudformation --region 'us-east-1'`;
|
||||
|
||||
const mockApi = ({ failFirstPoll = false } = {}): {
|
||||
createAccountPayload: unknown;
|
||||
pollCount: () => number;
|
||||
} => {
|
||||
let polls = 0;
|
||||
const api = {
|
||||
createAccountPayload: null as unknown,
|
||||
pollCount: (): number => polls,
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(AWS_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: credentials })),
|
||||
),
|
||||
rest.post(AWS_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
|
||||
api.createAccountPayload = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { id: ACCOUNT_ID, connectionArtifact: { aws: { cliCommand } } },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${AWS_ACCOUNTS_URL}/${ACCOUNT_ID}`, (_req, res, ctx) => {
|
||||
polls += 1;
|
||||
if (failFirstPoll && polls === 1) {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: ACCOUNT_ID,
|
||||
providerAccountId: null,
|
||||
agentReport: null,
|
||||
removedAt: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
describe('AWS CloudAccountSetupModal', () => {
|
||||
it('creates the account and shows its setup command', async () => {
|
||||
const api = mockApi();
|
||||
const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
const onClose = mockOnClose();
|
||||
|
||||
render(<CloudAccountSetupModal onClose={onClose} />);
|
||||
|
||||
await user.click(await screen.findByRole('combobox'));
|
||||
await user.click(await screen.findByText('US East (N. Virginia)'));
|
||||
await user.click(await screen.findByRole('checkbox', { name: /us-east-1/i }));
|
||||
await user.click(screen.getByTestId('aws-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('aws-agent-command')).toHaveTextContent(
|
||||
cliCommand,
|
||||
);
|
||||
});
|
||||
|
||||
expect(api.createAccountPayload).toStrictEqual({
|
||||
config: { aws: { deploymentRegion: 'us-east-1', regions: ['us-east-1'] } },
|
||||
credentials,
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('aws-generate-command-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
|
||||
expect(screen.getByText(/waiting for connection/i)).toBeInTheDocument();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
openSpy.mockRestore();
|
||||
|
||||
await user.click(screen.getByTestId('aws-cancel-btn'));
|
||||
await user.click(await screen.findByTestId('aws-continue-setup-btn'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('aws-continue-setup-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('aws-cancel-btn'));
|
||||
await user.click(await screen.findByTestId('aws-close-setup-btn'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes without asking before a setup command exists', async () => {
|
||||
mockApi();
|
||||
const user = userEvent.setup();
|
||||
const onClose = mockOnClose();
|
||||
|
||||
render(<CloudAccountSetupModal onClose={onClose} />);
|
||||
|
||||
await user.click(await screen.findByTestId('aws-cancel-btn'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('aws-close-setup-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resumes polling with Check Again after the connection check fails', async () => {
|
||||
const api = mockApi({ failFirstPoll: true });
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CloudAccountSetupModal onClose={mockOnClose()} />);
|
||||
|
||||
await user.click(await screen.findByRole('combobox'));
|
||||
await user.click(await screen.findByText('US East (N. Virginia)'));
|
||||
await user.click(await screen.findByRole('checkbox', { name: /us-east-1/i }));
|
||||
await user.click(screen.getByTestId('aws-generate-command-btn'));
|
||||
|
||||
await user.click(await screen.findByTestId('aws-check-connection-btn'));
|
||||
|
||||
await waitFor(() => expect(api.pollCount()).toBe(2));
|
||||
|
||||
await screen.findByText(/waiting for connection/i);
|
||||
expect(
|
||||
screen.queryByTestId('aws-check-connection-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('aws-agent-command')).toHaveTextContent(cliCommand);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,10 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LoaderCircle,
|
||||
SquareArrowOutUpRight,
|
||||
} from '@signozhq/icons';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, LoaderCircle } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Form, Select, Spin } from 'antd';
|
||||
import { useGetAccount } from 'api/generated/services/cloudintegration';
|
||||
import { CloudintegrationtypesAccountDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import CodeBlock from 'components/CodeBlock/CodeBlock';
|
||||
import {
|
||||
AZURE_REGIONS,
|
||||
INTEGRATION_TYPES,
|
||||
} from 'container/Integrations/constants';
|
||||
import { AZURE_REGIONS } from 'container/Integrations/constants';
|
||||
import {
|
||||
IntegrationModalProps,
|
||||
ModalStateEnum,
|
||||
@@ -25,15 +12,13 @@ import {
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { useIntegrationModal } from '../../../../../hooks/integration/azure/useIntegrationModal';
|
||||
import AgentSetupCommand from '../../AgentSetupCommand/AgentSetupCommand';
|
||||
import CloseSetupConfirm from '../../CloseSetupConfirm/CloseSetupConfirm';
|
||||
import { useCloseSetupConfirm } from '../../CloseSetupConfirm/useCloseSetupConfirm';
|
||||
import RenderConnectionFields from '../../AmazonWebServices/RegionForm/RenderConnectionParams';
|
||||
|
||||
import '../../AmazonWebServices/AddNewAccount/CloudAccountSetupModal.style.scss';
|
||||
|
||||
const AZURE_CLI_DESC =
|
||||
'Paste the following command if you have Azure CLI setup locally on your machine or use BASH CloudShell on Azure portal with above mentioned permissions.';
|
||||
const AZURE_POWERSHELL_DESC =
|
||||
'Paste the following command in PowerShell CloudShell on Azure portal, you can switch to PowerShell on Azure portal.';
|
||||
|
||||
function CloudAccountSetupModal({
|
||||
onClose,
|
||||
}: IntegrationModalProps): JSX.Element {
|
||||
@@ -41,53 +26,19 @@ function CloudAccountSetupModal({
|
||||
form,
|
||||
modalState,
|
||||
isLoading,
|
||||
accountId,
|
||||
connectionCommands,
|
||||
connectionCommand,
|
||||
handleSubmit,
|
||||
handleClose,
|
||||
connectionParams,
|
||||
isConnectionParamsLoading,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
checkConnectionAgain,
|
||||
} = useIntegrationModal({ onClose });
|
||||
|
||||
const startTimeRef = useRef(Date.now());
|
||||
const refetchInterval = 10 * 1000;
|
||||
const errorTimeout = 10 * 60 * 1000;
|
||||
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
|
||||
{ shouldConfirm: Boolean(connectionCommand), onClose: handleClose },
|
||||
);
|
||||
|
||||
const [isHowItWorksOpen, setIsHowItWorksOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('cli');
|
||||
|
||||
useGetAccount(
|
||||
{
|
||||
cloudProvider: INTEGRATION_TYPES.AZURE,
|
||||
id: accountId ?? '',
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: Boolean(accountId) && modalState === ModalStateEnum.WAITING,
|
||||
refetchInterval,
|
||||
select: (response): CloudintegrationtypesAccountDTO => response.data,
|
||||
onSuccess: (account) => {
|
||||
const isConnected =
|
||||
Boolean(account.providerAccountId) && account.removedAt === null;
|
||||
|
||||
if (isConnected) {
|
||||
handleConnectionSuccess({
|
||||
cloudAccountId: account.providerAccountId ?? account.id,
|
||||
status: account.agentReport,
|
||||
});
|
||||
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
|
||||
handleConnectionTimeout({ id: accountId });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
handleConnectionError();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const renderAlert = useCallback((): JSX.Element | null => {
|
||||
if (modalState === ModalStateEnum.WAITING) {
|
||||
@@ -96,14 +47,7 @@ function CloudAccountSetupModal({
|
||||
<Callout
|
||||
title={
|
||||
<div className="cloud-account-setup-form__alert-message">
|
||||
<Spin
|
||||
indicator={
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className="anticon anticon-loading anticon-spin ant-spin-dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Spin indicator={<LoaderCircle size={14} className="animate-spin" />} />
|
||||
Waiting for Azure account connection, retrying in{' '}
|
||||
<span className="retry-time">10</span> secs...
|
||||
</div>
|
||||
@@ -136,234 +80,233 @@ function CloudAccountSetupModal({
|
||||
|
||||
const footer = (
|
||||
<div className="cloud-account-setup-modal__footer">
|
||||
{modalState === ModalStateEnum.FORM && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={requestClose}
|
||||
testId="azure-cancel-btn"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{!connectionCommand && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />}
|
||||
onClick={handleSubmit}
|
||||
loading={isLoading}
|
||||
testId="azure-generate-command-btn"
|
||||
>
|
||||
Generate Azure Setup Commands
|
||||
Generate Command
|
||||
</Button>
|
||||
)}
|
||||
{modalState === ModalStateEnum.ERROR && connectionCommand && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={checkConnectionAgain}
|
||||
testId="azure-check-connection-btn"
|
||||
>
|
||||
Check Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className="cloud-account-setup-modal"
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Add Azure Account"
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
<div className="cloud-account-setup-modal__content">
|
||||
<div className="cloud-account-setup-prerequisites">
|
||||
<div className="cloud-account-setup-prerequisites__title">
|
||||
Prerequisites
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={true}
|
||||
className="cloud-account-setup-modal"
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
requestClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
showCloseButton
|
||||
title="Add Azure Account"
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
<div className="cloud-account-setup-modal__content">
|
||||
<div className="cloud-account-setup-prerequisites">
|
||||
<div className="cloud-account-setup-prerequisites__title">
|
||||
Prerequisites
|
||||
</div>
|
||||
|
||||
<ul className="cloud-account-setup-prerequisites__list">
|
||||
<li className="cloud-account-setup-prerequisites__list-item">
|
||||
<span className="cloud-account-setup-prerequisites__list-item-bullet">
|
||||
—
|
||||
</span>{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-text">
|
||||
Ensure that you're logged in to the Azure workspace which you want
|
||||
to monitor.
|
||||
</span>
|
||||
</li>
|
||||
<li className="cloud-account-setup-prerequisites__list-item">
|
||||
<span className="cloud-account-setup-prerequisites__list-item-bullet">
|
||||
—
|
||||
</span>{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-text">
|
||||
Ensure that you either have the{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-highlight">
|
||||
Owner
|
||||
</span>{' '}
|
||||
role OR
|
||||
</span>
|
||||
</li>
|
||||
<li className="cloud-account-setup-prerequisites__list-item">
|
||||
<span className="cloud-account-setup-prerequisites__list-item-bullet">
|
||||
—
|
||||
</span>{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-text">
|
||||
Both the{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-highlight">
|
||||
Contributor
|
||||
</span>{' '}
|
||||
and{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-highlight">
|
||||
user access admin
|
||||
</span>{' '}
|
||||
roles
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul className="cloud-account-setup-prerequisites__list">
|
||||
<li className="cloud-account-setup-prerequisites__list-item">
|
||||
<span className="cloud-account-setup-prerequisites__list-item-bullet">
|
||||
—
|
||||
</span>{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-text">
|
||||
Ensure that you're logged in to the Azure workspace which you want
|
||||
to monitor.
|
||||
</span>
|
||||
</li>
|
||||
<li className="cloud-account-setup-prerequisites__list-item">
|
||||
<span className="cloud-account-setup-prerequisites__list-item-bullet">
|
||||
—
|
||||
</span>{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-text">
|
||||
Ensure that you either have the{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-highlight">
|
||||
Owner
|
||||
</span>{' '}
|
||||
role OR
|
||||
</span>
|
||||
</li>
|
||||
<li className="cloud-account-setup-prerequisites__list-item">
|
||||
<span className="cloud-account-setup-prerequisites__list-item-bullet">
|
||||
—
|
||||
</span>{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-text">
|
||||
Both the{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-highlight">
|
||||
Contributor
|
||||
</span>{' '}
|
||||
and{' '}
|
||||
<span className="cloud-account-setup-prerequisites__list-item-highlight">
|
||||
user access admin
|
||||
</span>{' '}
|
||||
roles
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="cloud-account-setup-how-it-works-accordion">
|
||||
<div
|
||||
className={`cloud-account-setup-how-it-works-accordion__title ${
|
||||
isHowItWorksOpen ? 'open' : ''
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
icon
|
||||
aria-label="How it works"
|
||||
onClick={(): void => setIsHowItWorksOpen(!isHowItWorksOpen)}
|
||||
<div className="cloud-account-setup-how-it-works-accordion">
|
||||
<div
|
||||
className={`cloud-account-setup-how-it-works-accordion__title ${
|
||||
isHowItWorksOpen ? 'open' : ''
|
||||
}`}
|
||||
>
|
||||
{isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
|
||||
</Button>
|
||||
|
||||
<span className="cloud-account-setup-how-it-works-accordion__title-text">
|
||||
How it works?
|
||||
</span>
|
||||
</div>
|
||||
{isHowItWorksOpen && (
|
||||
<div className="cloud-account-setup-how-it-works-accordion__description">
|
||||
<div className="cloud-account-setup-how-it-works-accordion__description-item">
|
||||
SigNoz will create new resource-group to manage the resources required
|
||||
for this integration. The following steps will create a User-Assigned
|
||||
Managed Identity with the necessary permissions and follows the
|
||||
Principle of Least Privilege.
|
||||
</div>
|
||||
<div className="cloud-account-setup-how-it-works__description-item">
|
||||
Once the Integration template is deployed, you can enable the services
|
||||
you want to monitor right here in Signoz dashboard.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="cloud-account-setup-form"
|
||||
layout="vertical"
|
||||
initialValues={{ resourceGroups: [] }}
|
||||
>
|
||||
<div className="cloud-account-setup-form__content">
|
||||
<div className="cloud-account-setup-form__form-group">
|
||||
<div className="cloud-account-setup-form__title">
|
||||
Where should we deploy the SigNoz collector resources?
|
||||
</div>
|
||||
<div className="cloud-account-setup-form__description">
|
||||
Choose the Azure region for deployment.
|
||||
</div>
|
||||
<Form.Item
|
||||
name="region"
|
||||
rules={[{ required: true, message: 'Please select a region' }]}
|
||||
className="cloud-account-setup-form__form-item"
|
||||
<Button
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
icon
|
||||
aria-label="How it works"
|
||||
onClick={(): void => setIsHowItWorksOpen(!isHowItWorksOpen)}
|
||||
>
|
||||
<Select
|
||||
placeholder="e.g. East US"
|
||||
options={AZURE_REGIONS.map((region) => ({
|
||||
label: `${region.label} (${region.value})`,
|
||||
value: region.value,
|
||||
}))}
|
||||
getPopupContainer={popupContainer}
|
||||
disabled={modalState === ModalStateEnum.WAITING}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
|
||||
</Button>
|
||||
|
||||
<span className="cloud-account-setup-how-it-works-accordion__title-text">
|
||||
How it works?
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="cloud-account-setup-form__form-group">
|
||||
<div className="cloud-account-setup-form__title">
|
||||
Which resource groups do you want to monitor?
|
||||
</div>
|
||||
<div className="cloud-account-setup-form__description">
|
||||
Add one or more Azure resource group names.
|
||||
</div>
|
||||
<Form.Item
|
||||
name="resourceGroups"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one resource group',
|
||||
},
|
||||
]}
|
||||
className="cloud-account-setup-form__form-item"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="e.g. prod-platform-rg"
|
||||
tokenSeparators={[',']}
|
||||
disabled={modalState === ModalStateEnum.WAITING}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<RenderConnectionFields
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
isFormDisabled={modalState === ModalStateEnum.WAITING}
|
||||
/>
|
||||
|
||||
{connectionCommands && (
|
||||
<div className="cloud-account-setup-form__code-block-tabs-container">
|
||||
<div className="cloud-account-setup-form__code-block-tabs-header">
|
||||
<div className="cloud-account-setup-form__code-block-tabs-header-title">
|
||||
Deploy Agent
|
||||
</div>
|
||||
<div className="cloud-account-setup-form__code-block-tabs-header-description">
|
||||
{activeTab === 'cli' ? AZURE_CLI_DESC : AZURE_POWERSHELL_DESC}
|
||||
</div>
|
||||
{isHowItWorksOpen && (
|
||||
<div className="cloud-account-setup-how-it-works-accordion__description">
|
||||
<div className="cloud-account-setup-how-it-works-accordion__description-item">
|
||||
SigNoz will create new resource-group to manage the resources required
|
||||
for this integration. The following steps will create a User-Assigned
|
||||
Managed Identity with the necessary permissions and follows the
|
||||
Principle of Least Privilege.
|
||||
</div>
|
||||
<div className="cloud-account-setup-how-it-works__description-item">
|
||||
Once the Integration template is deployed, you can enable the services
|
||||
you want to monitor right here in Signoz dashboard.
|
||||
</div>
|
||||
<Tabs
|
||||
orientation="horizontal"
|
||||
alignment="start"
|
||||
className="cloud-account-setup-form__code-block-tabs"
|
||||
items={[
|
||||
{
|
||||
key: 'cli',
|
||||
label: 'CLI',
|
||||
children: <CodeBlock code={connectionCommands?.cliCommand || ''} />,
|
||||
},
|
||||
{
|
||||
key: 'powershell',
|
||||
label: 'PowerShell',
|
||||
children: (
|
||||
<CodeBlock
|
||||
code={connectionCommands?.cloudPowerShellCommand || ''}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={(key): void => setActiveTab(key)}
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderAlert()}
|
||||
|
||||
{modalState === ModalStateEnum.WAITING && (
|
||||
<div className="cloud-account-setup-status-message">
|
||||
After running the command, return here and wait for automatic connection
|
||||
detection.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="cloud-account-setup-form"
|
||||
layout="vertical"
|
||||
initialValues={{ resourceGroups: [] }}
|
||||
>
|
||||
<div className="cloud-account-setup-form__content">
|
||||
{connectionCommand ? (
|
||||
<AgentSetupCommand
|
||||
description="Run this command in Azure Cloud Shell (Bash), or in a terminal where the Azure CLI is signed in to the subscription you want to monitor. It contains your SigNoz keys, so do not share it."
|
||||
command={connectionCommand}
|
||||
testId="azure-agent-command"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="cloud-account-setup-form__form-group">
|
||||
<div className="cloud-account-setup-form__title">
|
||||
Where should we deploy the SigNoz collector resources?
|
||||
</div>
|
||||
<div className="cloud-account-setup-form__description">
|
||||
Choose the Azure region for deployment.
|
||||
</div>
|
||||
<Form.Item
|
||||
name="region"
|
||||
rules={[{ required: true, message: 'Please select a region' }]}
|
||||
className="cloud-account-setup-form__form-item"
|
||||
>
|
||||
<Select
|
||||
placeholder="e.g. East US"
|
||||
options={AZURE_REGIONS.map((region) => ({
|
||||
label: `${region.label} (${region.value})`,
|
||||
value: region.value,
|
||||
}))}
|
||||
getPopupContainer={popupContainer}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="cloud-account-setup-form__form-group">
|
||||
<div className="cloud-account-setup-form__title">
|
||||
Which resource groups do you want to monitor?
|
||||
</div>
|
||||
<div className="cloud-account-setup-form__description">
|
||||
Add one or more Azure resource group names.
|
||||
</div>
|
||||
<Form.Item
|
||||
name="resourceGroups"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: 'Please add at least one resource group',
|
||||
},
|
||||
]}
|
||||
className="cloud-account-setup-form__form-item"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="e.g. prod-platform-rg"
|
||||
tokenSeparators={[',']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<RenderConnectionFields
|
||||
isConnectionParamsLoading={isConnectionParamsLoading}
|
||||
connectionParams={connectionParams}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderAlert()}
|
||||
|
||||
{modalState === ModalStateEnum.WAITING && (
|
||||
<div className="cloud-account-setup-status-message">
|
||||
After running the command, return here and wait for automatic
|
||||
connection detection.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</DrawerWrapper>
|
||||
<CloseSetupConfirm
|
||||
providerName="Azure"
|
||||
testIdPrefix="azure"
|
||||
open={isConfirmOpen}
|
||||
onOpenChange={setIsConfirmOpen}
|
||||
onConfirm={handleClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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 type logEvent from '../../../../../api/common/logEvent';
|
||||
|
||||
import CloudAccountSetupModal from '../AddNewAccount/CloudAccountSetupModal';
|
||||
|
||||
// `useIntegrationModal` 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() as jest.MockedFunction<typeof logEvent>,
|
||||
}));
|
||||
|
||||
// The form flow drives antd selects and typing, which outruns the 5s default on CI.
|
||||
jest.setTimeout(15_000);
|
||||
|
||||
type OnClose = ComponentProps<typeof CloudAccountSetupModal>['onClose'];
|
||||
|
||||
const mockOnClose = (): jest.MockedFunction<OnClose> =>
|
||||
jest.fn() as jest.MockedFunction<OnClose>;
|
||||
|
||||
const AZURE_CREDENTIALS_URL =
|
||||
'http://localhost/api/v1/cloud_integrations/azure/credentials';
|
||||
const AZURE_ACCOUNTS_URL =
|
||||
'http://localhost/api/v1/cloud_integrations/azure/accounts';
|
||||
const ACCOUNT_ID = 'ci-azure-1234';
|
||||
|
||||
const credentials = {
|
||||
sigNozApiUrl: 'https://signoz.example.com',
|
||||
sigNozApiKey: 'signoz-api-key',
|
||||
ingestionUrl: 'https://ingest.example.com',
|
||||
ingestionKey: 'ingestion-key',
|
||||
};
|
||||
|
||||
const cliCommand = `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration azure bicep --region 'australiaeast'`;
|
||||
|
||||
const mockApi = ({ failFirstPoll = false } = {}): {
|
||||
createAccountPayload: unknown;
|
||||
pollCount: () => number;
|
||||
} => {
|
||||
let polls = 0;
|
||||
const api = {
|
||||
createAccountPayload: null as unknown,
|
||||
pollCount: (): number => polls,
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(AZURE_CREDENTIALS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: credentials })),
|
||||
),
|
||||
rest.post(AZURE_ACCOUNTS_URL, async (req: RestRequest, res, ctx) => {
|
||||
api.createAccountPayload = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { id: ACCOUNT_ID, connectionArtifact: { azure: { cliCommand } } },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${AZURE_ACCOUNTS_URL}/${ACCOUNT_ID}`, (_req, res, ctx) => {
|
||||
polls += 1;
|
||||
if (failFirstPoll && polls === 1) {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
id: ACCOUNT_ID,
|
||||
providerAccountId: null,
|
||||
agentReport: null,
|
||||
removedAt: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return api;
|
||||
};
|
||||
|
||||
describe('Azure CloudAccountSetupModal', () => {
|
||||
it('creates the account and shows its setup command', async () => {
|
||||
const api = mockApi();
|
||||
const user = userEvent.setup();
|
||||
const onClose = mockOnClose();
|
||||
|
||||
render(<CloudAccountSetupModal onClose={onClose} />);
|
||||
|
||||
const [region, resourceGroups] = await screen.findAllByRole('combobox');
|
||||
await user.click(region);
|
||||
await user.click(await screen.findByText('Australia East (australiaeast)'));
|
||||
await user.type(resourceGroups, 'prod-platform-rg{Enter}');
|
||||
await user.click(screen.getByTestId('azure-generate-command-btn'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('azure-agent-command')).toHaveTextContent(
|
||||
cliCommand,
|
||||
);
|
||||
});
|
||||
|
||||
expect(api.createAccountPayload).toStrictEqual({
|
||||
config: {
|
||||
azure: {
|
||||
deploymentRegion: 'australiaeast',
|
||||
resourceGroups: ['prod-platform-rg'],
|
||||
},
|
||||
},
|
||||
credentials,
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('azure-generate-command-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByRole('combobox')).toHaveLength(0);
|
||||
expect(
|
||||
screen.getByText(/waiting for azure account connection/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('azure-cancel-btn'));
|
||||
await user.click(await screen.findByTestId('azure-continue-setup-btn'));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('azure-continue-setup-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('azure-cancel-btn'));
|
||||
await user.click(await screen.findByTestId('azure-close-setup-btn'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes without asking before a setup command exists', async () => {
|
||||
mockApi();
|
||||
const user = userEvent.setup();
|
||||
const onClose = mockOnClose();
|
||||
|
||||
render(<CloudAccountSetupModal onClose={onClose} />);
|
||||
|
||||
await user.click(await screen.findByTestId('azure-cancel-btn'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('azure-close-setup-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resumes polling with Check Again after the connection check fails', async () => {
|
||||
const api = mockApi({ failFirstPoll: true });
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CloudAccountSetupModal onClose={mockOnClose()} />);
|
||||
|
||||
const [region, resourceGroups] = await screen.findAllByRole('combobox');
|
||||
await user.click(region);
|
||||
await user.click(await screen.findByText('Australia East (australiaeast)'));
|
||||
await user.type(resourceGroups, 'prod-platform-rg{Enter}');
|
||||
await user.click(screen.getByTestId('azure-generate-command-btn'));
|
||||
|
||||
await user.click(await screen.findByTestId('azure-check-connection-btn'));
|
||||
|
||||
await waitFor(() => expect(api.pollCount()).toBe(2));
|
||||
|
||||
await screen.findByText(/waiting for azure account connection/i);
|
||||
expect(
|
||||
screen.queryByTestId('azure-check-connection-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('azure-agent-command')).toHaveTextContent(
|
||||
cliCommand,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface CloseSetupConfirmProps {
|
||||
/** Provider name as shown in the title, e.g. `AWS`. */
|
||||
providerName: string;
|
||||
/** Prefix for the dialog's test ids, e.g. `aws`. */
|
||||
testIdPrefix: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function CloseSetupConfirm({
|
||||
providerName,
|
||||
testIdPrefix,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: CloseSetupConfirmProps): JSX.Element {
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={`Close ${providerName} agent setup?`}
|
||||
testId={`${testIdPrefix}-close-setup-confirm`}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="highlight-danger"
|
||||
onClick={onConfirm}
|
||||
testId={`${testIdPrefix}-close-setup-btn`}
|
||||
>
|
||||
Close Setup
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
testId={`${testIdPrefix}-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>
|
||||
);
|
||||
}
|
||||
|
||||
export default CloseSetupConfirm;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface UseCloseSetupConfirmProps {
|
||||
/** True once closing would lose a pending setup, i.e. a setup command exists. */
|
||||
shouldConfirm: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface UseCloseSetupConfirm {
|
||||
isConfirmOpen: boolean;
|
||||
setIsConfirmOpen: (open: boolean) => void;
|
||||
requestClose: () => void;
|
||||
}
|
||||
|
||||
export function useCloseSetupConfirm({
|
||||
shouldConfirm,
|
||||
onClose,
|
||||
}: UseCloseSetupConfirmProps): UseCloseSetupConfirm {
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
|
||||
const requestClose = useCallback((): void => {
|
||||
if (shouldConfirm) {
|
||||
setIsConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [shouldConfirm, onClose]);
|
||||
|
||||
return { isConfirmOpen, setIsConfirmOpen, requestClose };
|
||||
}
|
||||
@@ -3,3 +3,7 @@
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.mono {
|
||||
composes: mono from '../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
import { useAgentAccountSetup } from 'hooks/integration/gcp/useAgentAccountSetup';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import AgentSetupCommand from 'container/Integrations/CloudIntegration/AgentSetupCommand/AgentSetupCommand';
|
||||
import ConnectionMethodSelector from '../ConnectionMethodSelector';
|
||||
import ConnectionSecretsFields from '../ConnectionSecretsFields';
|
||||
import { AGENT_SETUP_GUIDE, DEFAULT_SETUP_FORM_VALUES } from '../constants';
|
||||
@@ -15,7 +16,6 @@ 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';
|
||||
|
||||
@@ -67,8 +67,15 @@ function AgentSetup({
|
||||
{connectionCommand ? (
|
||||
<>
|
||||
<AgentSetupCommand
|
||||
deploymentProjectId={commandProjectId}
|
||||
description={
|
||||
<>
|
||||
Run this command in Google Cloud Shell, or in a terminal where gcloud is
|
||||
signed in to <span className={styles.mono}>{commandProjectId}</span>. It
|
||||
contains your SigNoz keys, so do not share it.
|
||||
</>
|
||||
}
|
||||
command={connectionCommand}
|
||||
testId="gcp-agent-command"
|
||||
/>
|
||||
{modalState === ModalStateEnum.WAITING && (
|
||||
<Callout
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { CloudintegrationtypesGCPConnectionMethodDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IntegrationModalProps } from 'container/Integrations/HeroSection/types';
|
||||
|
||||
import CloseSetupConfirm from '../../CloseSetupConfirm/CloseSetupConfirm';
|
||||
import { useCloseSetupConfirm } from '../../CloseSetupConfirm/useCloseSetupConfirm';
|
||||
import AgentSetup from './AgentSetup/AgentSetup';
|
||||
import ManualSetup from './ManualSetup/ManualSetup';
|
||||
import SetupDrawerLayout from './SetupDrawerLayout';
|
||||
@@ -18,15 +17,9 @@ function CloudAccountSetupDrawer({
|
||||
);
|
||||
// Set once the agent command exists; closing after that loses the pending setup.
|
||||
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
|
||||
const requestClose = (): void => {
|
||||
if (hasGeneratedCommand) {
|
||||
setIsCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
|
||||
{ shouldConfirm: hasGeneratedCommand, onClose },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -49,39 +42,13 @@ function CloudAccountSetupDrawer({
|
||||
)
|
||||
}
|
||||
</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>
|
||||
<CloseSetupConfirm
|
||||
providerName="GCP"
|
||||
testIdPrefix="gcp"
|
||||
open={isConfirmOpen}
|
||||
onOpenChange={setIsConfirmOpen}
|
||||
onConfirm={onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,18 +20,12 @@ export interface RegionFormProps {
|
||||
includeAllRegions: boolean;
|
||||
onRegionSelect: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
accountId?: string;
|
||||
connectionCommand: string | null;
|
||||
handleRegionChange: (value: string) => void;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading?: boolean;
|
||||
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
|
||||
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
|
||||
onConnectionSuccess: (payload: {
|
||||
cloudAccountId: string;
|
||||
status?: unknown;
|
||||
}) => void;
|
||||
onConnectionTimeout: (payload: { id?: string }) => void;
|
||||
onConnectionError: () => void;
|
||||
}
|
||||
|
||||
export interface IntegrationModalProps {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('ListAlertRules — row click navigation', () => {
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
expect(url).toContain('/alerts/overview?');
|
||||
expect(url).toContain('ruleId=rule-1');
|
||||
expect(url).toContain('panelTypes=graph');
|
||||
expect(url).not.toContain('panelTypes');
|
||||
expect(url).toContain('compositeQuery=');
|
||||
});
|
||||
|
||||
|
||||
@@ -36,11 +36,6 @@ export function useAlertRulesHandlers(
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = rule.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, rule.id);
|
||||
|
||||
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
|
||||
import { parseLimit, sanitizeLimit } from 'components/QueryBuilderV2/utils';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
// ** Hooks
|
||||
@@ -171,7 +172,8 @@ export function Formula({
|
||||
<InputWithLabel
|
||||
label="Limit"
|
||||
type="number"
|
||||
onChange={(value): void => handleChangeLimit(Number(value))}
|
||||
onChange={(value): void => handleChangeLimit(parseLimit(value))}
|
||||
sanitize={sanitizeLimit}
|
||||
initialValue={formula?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ const renderPolling = (): void => {
|
||||
renderHook(
|
||||
() =>
|
||||
useAgentConnectionPolling({
|
||||
cloudProvider: 'gcp',
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled: true,
|
||||
...callbacks,
|
||||
@@ -21,6 +21,7 @@ const renderPolling = (
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }: { enabled: boolean }) =>
|
||||
useAgentConnectionPolling({
|
||||
cloudProvider: 'gcp',
|
||||
accountId: ACCOUNT_ID,
|
||||
enabled,
|
||||
...callbacks,
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
GetConnectionCredentialsQueryResult,
|
||||
invalidateListAccounts,
|
||||
useCreateAccount,
|
||||
useGetConnectionCredentials,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import { useGetConnectionCredentials } from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesPostableAccountDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -29,6 +30,7 @@ import useAxiosError from 'hooks/useAxiosError';
|
||||
import { regions } from 'utils/regions';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
|
||||
interface UseIntegrationModalProps {
|
||||
onClose: () => void;
|
||||
@@ -42,7 +44,8 @@ interface UseIntegrationModal {
|
||||
activeView: ActiveViewEnum;
|
||||
selectedRegions: string[];
|
||||
includeAllRegions: boolean;
|
||||
isGeneratingUrl: boolean;
|
||||
isGeneratingCommand: boolean;
|
||||
connectionCommand: string | null;
|
||||
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
|
||||
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
|
||||
handleRegionSelect: () => void;
|
||||
@@ -50,17 +53,11 @@ interface UseIntegrationModal {
|
||||
handleClose: () => void;
|
||||
setActiveView: (view: ActiveViewEnum) => void;
|
||||
allRegions: string[];
|
||||
accountId?: string;
|
||||
selectedDeploymentRegion: string | undefined;
|
||||
handleRegionChange: (value: string) => void;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
handleConnectionSuccess: (payload: {
|
||||
cloudAccountId: string;
|
||||
status?: unknown;
|
||||
}) => void;
|
||||
handleConnectionTimeout: (payload: { id?: string }) => void;
|
||||
handleConnectionError: () => void;
|
||||
checkConnectionAgain: () => void;
|
||||
}
|
||||
|
||||
export function useIntegrationModal({
|
||||
@@ -73,6 +70,9 @@ export function useIntegrationModal({
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [accountId, setAccountId] = useState<string | undefined>();
|
||||
const [connectionCommand, setConnectionCommand] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [activeView, setActiveView] = useState<ActiveViewEnum>(
|
||||
ActiveViewEnum.FORM,
|
||||
);
|
||||
@@ -103,14 +103,15 @@ export function useIntegrationModal({
|
||||
setSelectedRegions([]);
|
||||
setIncludeAllRegions(false);
|
||||
setModalState(ModalStateEnum.FORM);
|
||||
setConnectionCommand(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleConnectionSuccess = useCallback(
|
||||
(payload: { cloudAccountId: string; status?: unknown }): void => {
|
||||
(account: CloudintegrationtypesAccountDTO): void => {
|
||||
void logEvent('AWS Integration: Account connected', {
|
||||
cloudAccountId: payload.cloudAccountId,
|
||||
status: payload.status,
|
||||
cloudAccountId: account.providerAccountId ?? account.id,
|
||||
status: account.agentReport,
|
||||
});
|
||||
toast.success('AWS account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
@@ -123,21 +124,32 @@ export function useIntegrationModal({
|
||||
[handleClose, queryClient],
|
||||
);
|
||||
|
||||
const handleConnectionTimeout = useCallback(
|
||||
(payload: { id?: string }): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
void logEvent('AWS Integration: Account connection attempt timed out', {
|
||||
id: payload.id,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleConnectionTimeout = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
void logEvent('AWS Integration: Account connection attempt timed out', {
|
||||
id: accountId,
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
const handleConnectionError = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
}, []);
|
||||
|
||||
const { mutate: generateUrl, isLoading: isGeneratingUrl } = useCreateAccount();
|
||||
const checkConnectionAgain = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
}, []);
|
||||
|
||||
useAgentConnectionPolling({
|
||||
cloudProvider: INTEGRATION_TYPES.AWS,
|
||||
accountId: accountId ?? '',
|
||||
enabled: modalState === ModalStateEnum.WAITING && accountId !== undefined,
|
||||
onConnected: handleConnectionSuccess,
|
||||
onTimeout: handleConnectionTimeout,
|
||||
onError: handleConnectionError,
|
||||
});
|
||||
|
||||
const { mutate: createAccount, isLoading: isGeneratingCommand } =
|
||||
useCreateAccount();
|
||||
|
||||
const handleError = useAxiosError();
|
||||
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
|
||||
@@ -152,9 +164,9 @@ export function useIntegrationModal({
|
||||
},
|
||||
);
|
||||
|
||||
const handleGenerateUrl = useCallback(
|
||||
const handleGenerateCommand = useCallback(
|
||||
(payload: CloudintegrationtypesPostableAccountDTO): void => {
|
||||
generateUrl(
|
||||
createAccount(
|
||||
{
|
||||
pathParams: { cloudProvider: INTEGRATION_TYPES.AWS },
|
||||
data: payload,
|
||||
@@ -162,17 +174,19 @@ export function useIntegrationModal({
|
||||
{
|
||||
onSuccess: (response: CreateAccountMutationResult) => {
|
||||
const accountId = response.data.id;
|
||||
const connectionUrl =
|
||||
response.data.connectionArtifact.aws?.connectionUrl ?? '';
|
||||
const command = response.data.connectionArtifact.aws?.cliCommand;
|
||||
if (!command) {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
toast.error('Failed to generate the AWS setup command', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void logEvent(
|
||||
'AWS Integration: Account connection attempt redirected to AWS',
|
||||
{
|
||||
id: accountId,
|
||||
},
|
||||
);
|
||||
// oxlint-disable-next-line signoz/no-raw-absolute-path -- connectionUrl is an external AWS console URL, not an internal path
|
||||
window.open(connectionUrl, '_blank');
|
||||
void logEvent('AWS Integration: Account connection command generated', {
|
||||
id: accountId,
|
||||
});
|
||||
setConnectionCommand(command);
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
setAccountId(accountId);
|
||||
},
|
||||
@@ -185,7 +199,7 @@ export function useIntegrationModal({
|
||||
},
|
||||
);
|
||||
},
|
||||
[generateUrl],
|
||||
[createAccount],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
@@ -208,13 +222,13 @@ export function useIntegrationModal({
|
||||
},
|
||||
};
|
||||
|
||||
handleGenerateUrl(payload);
|
||||
handleGenerateCommand(payload);
|
||||
} catch (error) {
|
||||
console.error('Form submission failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [form, selectedRegions, handleGenerateUrl, connectionParams]);
|
||||
}, [form, selectedRegions, handleGenerateCommand, connectionParams]);
|
||||
|
||||
return {
|
||||
form,
|
||||
@@ -223,7 +237,8 @@ export function useIntegrationModal({
|
||||
activeView,
|
||||
selectedRegions,
|
||||
includeAllRegions,
|
||||
isGeneratingUrl,
|
||||
isGeneratingCommand,
|
||||
connectionCommand,
|
||||
setSelectedRegions,
|
||||
setIncludeAllRegions,
|
||||
handleRegionSelect,
|
||||
@@ -231,7 +246,6 @@ export function useIntegrationModal({
|
||||
handleClose,
|
||||
setActiveView,
|
||||
allRegions,
|
||||
accountId,
|
||||
setModalState,
|
||||
selectedDeploymentRegion,
|
||||
handleRegionChange,
|
||||
@@ -239,8 +253,6 @@ export function useIntegrationModal({
|
||||
| CloudintegrationtypesCredentialsDTO
|
||||
| undefined,
|
||||
isConnectionParamsLoading,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
checkConnectionAgain,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useGetConnectionCredentials,
|
||||
} from 'api/generated/services/cloudintegration';
|
||||
import {
|
||||
CloudintegrationtypesAccountDTO,
|
||||
CloudintegrationtypesCredentialsDTO,
|
||||
CloudintegrationtypesPostableAccountDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -18,6 +19,7 @@ import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
import useAxiosError from 'hooks/useAxiosError';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
|
||||
interface UseIntegrationModalProps {
|
||||
onClose: () => void;
|
||||
@@ -27,22 +29,13 @@ interface UseAzureIntegrationModal {
|
||||
form: FormInstance;
|
||||
modalState: ModalStateEnum;
|
||||
isLoading: boolean;
|
||||
accountId?: string;
|
||||
connectionCommands: {
|
||||
cliCommand: string;
|
||||
cloudPowerShellCommand: string;
|
||||
} | null;
|
||||
connectionCommand: string | null;
|
||||
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
|
||||
handleSubmit: () => Promise<void>;
|
||||
handleClose: () => void;
|
||||
connectionParams?: CloudintegrationtypesCredentialsDTO;
|
||||
isConnectionParamsLoading: boolean;
|
||||
handleConnectionSuccess: (payload: {
|
||||
cloudAccountId: string;
|
||||
status?: unknown;
|
||||
}) => void;
|
||||
handleConnectionTimeout: (payload: { id?: string }) => void;
|
||||
handleConnectionError: () => void;
|
||||
checkConnectionAgain: () => void;
|
||||
}
|
||||
|
||||
export function useIntegrationModal({
|
||||
@@ -55,22 +48,21 @@ export function useIntegrationModal({
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [accountId, setAccountId] = useState<string | undefined>(undefined);
|
||||
const [connectionCommands, setConnectionCommands] = useState<{
|
||||
cliCommand: string;
|
||||
cloudPowerShellCommand: string;
|
||||
} | null>(null);
|
||||
const [connectionCommand, setConnectionCommand] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.FORM);
|
||||
setConnectionCommands(null);
|
||||
setConnectionCommand(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleConnectionSuccess = useCallback(
|
||||
(payload: { cloudAccountId: string; status?: unknown }): void => {
|
||||
logEvent('Azure Integration: Account connected', {
|
||||
cloudAccountId: payload.cloudAccountId,
|
||||
status: payload.status,
|
||||
(account: CloudintegrationtypesAccountDTO): void => {
|
||||
void logEvent('Azure Integration: Account connected', {
|
||||
cloudAccountId: account.providerAccountId ?? account.id,
|
||||
status: account.agentReport,
|
||||
});
|
||||
toast.success('Azure account connected successfully', {
|
||||
position: 'bottom-right',
|
||||
@@ -83,20 +75,30 @@ export function useIntegrationModal({
|
||||
[handleClose, queryClient],
|
||||
);
|
||||
|
||||
const handleConnectionTimeout = useCallback(
|
||||
(payload: { id?: string }): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
logEvent('Azure Integration: Account connection attempt timed out', {
|
||||
id: payload.id,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleConnectionTimeout = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
void logEvent('Azure Integration: Account connection attempt timed out', {
|
||||
id: accountId,
|
||||
});
|
||||
}, [accountId]);
|
||||
|
||||
const handleConnectionError = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
}, []);
|
||||
|
||||
const checkConnectionAgain = useCallback((): void => {
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
}, []);
|
||||
|
||||
useAgentConnectionPolling({
|
||||
cloudProvider: INTEGRATION_TYPES.AZURE,
|
||||
accountId: accountId ?? '',
|
||||
enabled: modalState === ModalStateEnum.WAITING && accountId !== undefined,
|
||||
onConnected: handleConnectionSuccess,
|
||||
onTimeout: handleConnectionTimeout,
|
||||
onError: handleConnectionError,
|
||||
});
|
||||
|
||||
const { mutate: createAccount } = useCreateAccount();
|
||||
const handleError = useAxiosError();
|
||||
|
||||
@@ -140,16 +142,20 @@ export function useIntegrationModal({
|
||||
{
|
||||
onSuccess: (response: CreateAccountMutationResult) => {
|
||||
const nextAccountId = response.data.id;
|
||||
const artifact = response.data.connectionArtifact.azure;
|
||||
const command = response.data.connectionArtifact.azure?.cliCommand;
|
||||
if (!command) {
|
||||
setModalState(ModalStateEnum.ERROR);
|
||||
toast.error('Failed to generate the Azure setup command', {
|
||||
position: 'bottom-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logEvent('Azure Integration: Account connection commands generated', {
|
||||
void logEvent('Azure Integration: Account connection command generated', {
|
||||
id: nextAccountId,
|
||||
});
|
||||
|
||||
setConnectionCommands({
|
||||
cliCommand: artifact?.cliCommand || '',
|
||||
cloudPowerShellCommand: artifact?.cloudPowerShellCommand || '',
|
||||
});
|
||||
setConnectionCommand(command);
|
||||
setModalState(ModalStateEnum.WAITING);
|
||||
setAccountId(nextAccountId);
|
||||
},
|
||||
@@ -172,8 +178,7 @@ export function useIntegrationModal({
|
||||
form,
|
||||
modalState,
|
||||
isLoading,
|
||||
accountId,
|
||||
connectionCommands,
|
||||
connectionCommand,
|
||||
setModalState,
|
||||
handleSubmit,
|
||||
handleClose,
|
||||
@@ -181,8 +186,6 @@ export function useIntegrationModal({
|
||||
| CloudintegrationtypesCredentialsDTO
|
||||
| undefined,
|
||||
isConnectionParamsLoading,
|
||||
handleConnectionSuccess,
|
||||
handleConnectionTimeout,
|
||||
handleConnectionError,
|
||||
checkConnectionAgain,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
CloudintegrationtypesGCPConnectionMethodDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GcpSetupFormValues } from 'container/Integrations/CloudIntegration/GoogleCloudPlatform/AddNewAccount/types';
|
||||
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
|
||||
import { ModalStateEnum } from 'container/Integrations/HeroSection/types';
|
||||
|
||||
import logEvent from '../../../api/common/logEvent';
|
||||
import { useAgentConnectionPolling } from './useAgentConnectionPolling';
|
||||
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
|
||||
import { useGcpAccountSetup } from './useGcpAccountSetup';
|
||||
|
||||
const GENERATE_COMMAND_ERROR = 'Failed to generate the GCP setup command';
|
||||
@@ -116,6 +117,7 @@ export function useAgentAccountSetup({
|
||||
}, []);
|
||||
|
||||
useAgentConnectionPolling({
|
||||
cloudProvider: INTEGRATION_TYPES.GCP,
|
||||
accountId: accountId ?? '',
|
||||
enabled: modalState === ModalStateEnum.WAITING && accountId !== null,
|
||||
onConnected: handleAgentConnected,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 {
|
||||
cloudProvider: string;
|
||||
accountId: string;
|
||||
/** Polls exactly while true; the caller decides when there is an account to poll. */
|
||||
enabled: boolean;
|
||||
@@ -16,10 +16,11 @@ interface UseAgentConnectionPollingProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Polls a cloud integration account until its agent checks in. The timeout is
|
||||
* measured from when polling gets enabled, so re-enabling it starts a fresh window.
|
||||
*/
|
||||
export function useAgentConnectionPolling({
|
||||
cloudProvider,
|
||||
accountId,
|
||||
enabled,
|
||||
onConnected,
|
||||
@@ -33,7 +34,7 @@ export function useAgentConnectionPolling({
|
||||
}, [enabled]);
|
||||
|
||||
useGetAccount(
|
||||
{ cloudProvider: INTEGRATION_TYPES.GCP, id: accountId },
|
||||
{ cloudProvider, id: accountId },
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
@@ -141,6 +141,9 @@ describe('useCreateAlerts', () => {
|
||||
|
||||
const composite = getCompositeQueryFromLastOpen();
|
||||
expect(composite.queryType).toBe(EQueryType.PROM);
|
||||
|
||||
const [url] = (window.open as jest.Mock).mock.calls[0];
|
||||
expect(url).not.toContain('panelTypes');
|
||||
});
|
||||
|
||||
it('falls back to the mapped queryType when widget has no queryType', () => {
|
||||
|
||||
@@ -83,7 +83,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
params.set(QueryParams.panelTypes, widget.panelTypes);
|
||||
params.set(QueryParams.version, ENTITY_VERSION_V5);
|
||||
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TestRouter } from 'tests/router';
|
||||
|
||||
import { useGetPanelTypesQueryParam } from './useGetPanelTypesQueryParam';
|
||||
|
||||
const renderWithSearch = (
|
||||
search: string,
|
||||
defaultPanelType?: PANEL_TYPES,
|
||||
): PANEL_TYPES | null => {
|
||||
const { result } = renderHook(
|
||||
() => useGetPanelTypesQueryParam(defaultPanelType),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<TestRouter initialRoute={`/logs/logs-explorer${search}`}>
|
||||
{children}
|
||||
</TestRouter>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
return result.current;
|
||||
};
|
||||
|
||||
describe('useGetPanelTypesQueryParam', () => {
|
||||
it.each([
|
||||
['a JSON encoded panel type', '?panelTypes=%22table%22', PANEL_TYPES.TABLE],
|
||||
['a plain string panel type', '?panelTypes=graph', PANEL_TYPES.TIME_SERIES],
|
||||
['an unparseable panel type', '?panelTypes=%7Bfoo', PANEL_TYPES.LIST],
|
||||
[
|
||||
'a value that is not a panel type',
|
||||
'?panelTypes=%22nope%22',
|
||||
PANEL_TYPES.LIST,
|
||||
],
|
||||
['an absent param', '', PANEL_TYPES.LIST],
|
||||
])('reads %s', (_, search, expected) => {
|
||||
expect(renderWithSearch(search, PANEL_TYPES.LIST)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { parsePanelType } from 'utils/parsePanelType';
|
||||
|
||||
export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
|
||||
defaultPanelType?: T,
|
||||
@@ -11,6 +12,10 @@ export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
|
||||
return useMemo(() => {
|
||||
const panelTypeQuery = urlQuery.get(QueryParams.panelTypes);
|
||||
|
||||
return panelTypeQuery ? JSON.parse(panelTypeQuery) : defaultPanelType;
|
||||
}, [urlQuery, defaultPanelType]);
|
||||
return (
|
||||
(panelTypeQuery ? parsePanelType(panelTypeQuery) : null) ?? defaultPanelType
|
||||
);
|
||||
}, [urlQuery, defaultPanelType]) as T extends undefined
|
||||
? PANEL_TYPES | null
|
||||
: PANEL_TYPES;
|
||||
};
|
||||
|
||||
@@ -184,7 +184,6 @@ describe('useCreateAlertFromPanel', () => {
|
||||
// The resolved query is seeded with the panel-derived alert prefill.
|
||||
expect(mockBuildAlertUrl).toHaveBeenCalledWith(
|
||||
{ resolved: 'query' },
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
undefined,
|
||||
mockPrefill,
|
||||
);
|
||||
|
||||
@@ -88,7 +88,6 @@ export function useCreateAlertFromPanel(): (
|
||||
const unit = readPanelUnit(panel.spec.plugin);
|
||||
const url = buildAlertUrl(
|
||||
query,
|
||||
panelType,
|
||||
unit,
|
||||
deriveAlertPrefill(panel, query, unit),
|
||||
);
|
||||
|
||||
@@ -65,14 +65,19 @@ describe('buildCreateAlertUrl', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
|
||||
it('tags the URL with the v5 version and the dashboards source', () => {
|
||||
const params = parse(buildCreateAlertUrl(makePanel()));
|
||||
|
||||
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
|
||||
expect(params.get(QueryParams.source)).toBe('dashboards');
|
||||
});
|
||||
|
||||
it('does not tag the URL with a panel type, which the alert page ignores', () => {
|
||||
const params = parse(buildCreateAlertUrl(makePanel()));
|
||||
|
||||
expect(params.get(QueryParams.panelTypes)).toBeNull();
|
||||
});
|
||||
|
||||
it('encodes the translated query as the compositeQuery param', () => {
|
||||
const params = parse(buildCreateAlertUrl(makePanel()));
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
@@ -34,7 +33,6 @@ export function readPanelUnit(
|
||||
*/
|
||||
export function buildAlertUrl(
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
unit?: string,
|
||||
prefill?: PanelAlertPrefill,
|
||||
): string {
|
||||
@@ -48,7 +46,6 @@ export function buildAlertUrl(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
params.set(QueryParams.version, ENTITY_VERSION_V5);
|
||||
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
|
||||
|
||||
@@ -76,10 +73,5 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
|
||||
const panelType = toPanelType(panel.spec.plugin.kind);
|
||||
const query = fromPerses(panel.spec.queries, panelType);
|
||||
const unit = readPanelUnit(panel.spec.plugin);
|
||||
return buildAlertUrl(
|
||||
query,
|
||||
panelType,
|
||||
unit,
|
||||
deriveAlertPrefill(panel, query, unit),
|
||||
);
|
||||
return buildAlertUrl(query, unit, deriveAlertPrefill(panel, query, unit));
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export const cloudIntegrationMocks = defineStoryMocks({
|
||||
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.',
|
||||
'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 with the AWS, Azure or GCP agent setup command up, `error` fails the poll so the drawer reports it could not connect.',
|
||||
options: NEW_ACCOUNT_CHECK_INS,
|
||||
value: 'checked-in',
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ export const connectAwsAccount = async (
|
||||
await screen.findByRole('checkbox', { name: /us-east-1/i }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /launch cloud formation/i }),
|
||||
await screen.findByRole('button', { name: /generate command/i }),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -339,7 +339,7 @@ export const connectAzureAccount = async (
|
||||
);
|
||||
await userEvent.type(resourceGroups, 'prod-platform-rg{Enter}');
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: /generate azure setup commands/i }),
|
||||
await screen.findByRole('button', { name: /generate command/i }),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -173,8 +173,8 @@ export const TooltipsInDataCollected: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Connecting an AWS account: region and monitored regions picked, the template
|
||||
* launched, and the first poll finds the account connected. Set Account
|
||||
* Connecting an AWS account: region and monitored regions picked, the setup
|
||||
* command generated, and the first poll finds the account connected. Set Account
|
||||
* connection to `error` for the failure toast.
|
||||
*/
|
||||
export const AwsAccountConnectedToast: Story = {
|
||||
@@ -198,7 +198,7 @@ export const AwsAccountConnectionErrorToast: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Connecting an Azure subscription through the generated setup commands. */
|
||||
/** Connecting an Azure subscription through the generated setup command. */
|
||||
export const AzureAccountConnectedToast: Story = {
|
||||
args: { provider: 'azure', accounts: 0 },
|
||||
play: async ({ canvasElement, args }): Promise<void> => {
|
||||
@@ -220,6 +220,89 @@ export const AzureAccountConnectionErrorToast: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The AWS setup command, with the drawer waiting for the account to connect.
|
||||
* Set New account check-in to `checked-in` or `error` to see it connect or fail.
|
||||
*/
|
||||
export const AwsAgentCommand: Story = {
|
||||
args: { accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await connectAwsAccount(canvasElement);
|
||||
await screen.findByTestId('aws-agent-command', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The connection poll fails, so the drawer reports it could not connect and
|
||||
* offers Check Again.
|
||||
*/
|
||||
export const AwsAgentConnectionError: Story = {
|
||||
args: { accounts: 0, newAccountCheckIn: 'error' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await connectAwsAccount(canvasElement);
|
||||
await screen.findByTestId('aws-check-connection-btn', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Leaving the drawer while the agent has not connected yet, which asks first
|
||||
* because closing means starting the installation again.
|
||||
*/
|
||||
export const AwsAgentCloseConfirm: Story = {
|
||||
args: { accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await connectAwsAccount(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('aws-cancel-btn', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByTestId('aws-close-setup-btn', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The Azure setup command, with the drawer waiting for the subscription to
|
||||
* connect. Set New account check-in to `checked-in` or `error` to see it connect
|
||||
* or fail.
|
||||
*/
|
||||
export const AzureAgentCommand: Story = {
|
||||
args: { provider: 'azure', accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await connectAzureAccount(canvasElement);
|
||||
await screen.findByTestId('azure-agent-command', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The connection poll fails, so the drawer reports it could not connect and
|
||||
* offers Check Again.
|
||||
*/
|
||||
export const AzureAgentConnectionError: Story = {
|
||||
args: { provider: 'azure', accounts: 0, newAccountCheckIn: 'error' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await connectAzureAccount(canvasElement);
|
||||
await screen.findByTestId(
|
||||
'azure-check-connection-btn',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Leaving the drawer while the agent has not connected yet, which asks first
|
||||
* because closing means starting the installation again.
|
||||
*/
|
||||
export const AzureAgentCloseConfirm: Story = {
|
||||
args: { provider: 'azure', accounts: 0, newAccountCheckIn: 'pending' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await connectAzureAccount(canvasElement);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId('azure-cancel-btn', undefined, untilLoaded),
|
||||
);
|
||||
await screen.findByTestId('azure-close-setup-btn', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Connecting a GCP account manually: the account is created and the story's
|
||||
* check-in stands in for the agent. A failure at either step shows inline in the
|
||||
|
||||
@@ -366,6 +366,30 @@ export const accountResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
/** The backend's command template, filled in for the story's region. */
|
||||
const AWS_AGENT_COMMAND = {
|
||||
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration aws cloudformation \\
|
||||
--region 'us-east-1' \\
|
||||
--integration-account-id 'aws-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'`,
|
||||
};
|
||||
|
||||
/** The backend's command template, filled in for the story's region. */
|
||||
const AZURE_AGENT_COMMAND = {
|
||||
cliCommand: `curl -fsSL 'https://signoz.io/foundry.sh' | bash -s -- cloudintegration azure bicep \\
|
||||
--region 'australiaeast' \\
|
||||
--integration-account-id 'azure-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'`,
|
||||
};
|
||||
|
||||
/** 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 \\
|
||||
@@ -613,9 +637,8 @@ 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.
|
||||
* GCP returns its setup command only for an agent connection; a manual one gets
|
||||
* none.
|
||||
*/
|
||||
export const createdAccountResponse = (
|
||||
provider: CloudProvider,
|
||||
@@ -625,15 +648,8 @@ export const createdAccountResponse = (
|
||||
data: {
|
||||
id: newAccountId(provider),
|
||||
connectionArtifact: {
|
||||
aws: {
|
||||
connectionUrl: `https://console.aws.amazon.com/cloudformation/home#/stacks/create?templateURL=https://signoz.io/${provider}.yaml`,
|
||||
},
|
||||
azure: {
|
||||
cliCommand:
|
||||
'az deployment sub create --template-uri https://signoz.io/azure.json',
|
||||
cloudPowerShellCommand:
|
||||
'New-AzSubscriptionDeployment -TemplateUri https://signoz.io/azure.json',
|
||||
},
|
||||
aws: AWS_AGENT_COMMAND,
|
||||
azure: AZURE_AGENT_COMMAND,
|
||||
...(gcpConnectionMethod ===
|
||||
CloudintegrationtypesGCPConnectionMethodDTO.agent && {
|
||||
gcp: GCP_AGENT_COMMAND,
|
||||
|
||||
@@ -118,12 +118,8 @@ export function QueryBuilderProvider({
|
||||
null,
|
||||
);
|
||||
|
||||
const panelTypeQueryParams = getUnstableCurrentSearchParams().get(
|
||||
QueryParams.panelTypes,
|
||||
) as PANEL_TYPES | null;
|
||||
|
||||
const [panelType, setPanelType] = useState<PANEL_TYPES | null>(
|
||||
panelTypeQueryParams,
|
||||
const [panelType, setPanelType] = useState<PANEL_TYPES | null>(() =>
|
||||
readPanelTypeFromUrl(),
|
||||
);
|
||||
|
||||
const [currentQuery, setCurrentQuery] = useState<QueryState>(queryState);
|
||||
@@ -475,12 +471,17 @@ export function QueryBuilderProvider({
|
||||
const createNewBuilderQuery = useCallback(
|
||||
(queries: IBuilderQuery[]): IBuilderQuery => {
|
||||
const existNames = queries.map((item) => item.queryName);
|
||||
// Hosts without a fixed data source (the panel editor) continue the
|
||||
// previous query's signal rather than resetting to metrics.
|
||||
const previous = queries[queries.length - 1];
|
||||
const initialBuilderQuery =
|
||||
initialQueryBuilderFormValuesMap[initialDataSource || DataSource.METRICS];
|
||||
initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || previous?.dataSource || DataSource.METRICS
|
||||
];
|
||||
|
||||
const newQuery: IBuilderQuery = {
|
||||
...initialBuilderQuery,
|
||||
source: queries?.[0]?.source || '',
|
||||
source: previous?.source || '',
|
||||
builderQueryType: queries?.[0]?.builderQueryType,
|
||||
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
|
||||
expression: createNewBuilderItemName({
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const renderQueryBuilder = (
|
||||
initialQuery: Query,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
|
||||
> => {
|
||||
const hook = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
hook.result.current.initQueryBuilderData(initialQuery);
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
describe('createNewBuilderQuery data source', () => {
|
||||
it.each([DataSource.LOGS, DataSource.TRACES, DataSource.METRICS])(
|
||||
'continues the previous %s query when the host fixes no data source',
|
||||
(dataSource) => {
|
||||
const { result } = renderQueryBuilder(initialQueriesMap[dataSource]);
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
const [, added] = result.current.currentQuery.builder.queryData;
|
||||
expect(added.dataSource).toBe(dataSource);
|
||||
expect(added.aggregations).toStrictEqual(
|
||||
initialQueryBuilderFormValuesMap[dataSource].aggregations,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('follows the last query when queries mix signals', () => {
|
||||
const { result } = renderQueryBuilder(initialQueriesMap.logs);
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleSetQueryData(1, {
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData[2].dataSource).toBe(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the source from the last query, not the first', () => {
|
||||
const [metricsQuery] = initialQueriesMap.metrics.builder.queryData;
|
||||
const { result } = renderQueryBuilder({
|
||||
...initialQueriesMap.metrics,
|
||||
builder: {
|
||||
...initialQueriesMap.metrics.builder,
|
||||
queryData: [
|
||||
{ ...metricsQuery, source: '' },
|
||||
{ ...metricsQuery, queryName: 'B', expression: 'B', source: 'meter' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData[2]).toMatchObject({
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the host’s fixed data source over the previous query', () => {
|
||||
const { result } = renderQueryBuilder(initialQueriesMap.metrics);
|
||||
act(() => {
|
||||
result.current.handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData[1].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
const renderWithPanelTypes = (value: string): PANEL_TYPES | null => {
|
||||
__setSearchParamsGetterForTest(
|
||||
() => new URLSearchParams({ panelTypes: value }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
return result.current.panelType;
|
||||
};
|
||||
|
||||
describe('QueryBuilderProvider panel type seed', () => {
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a JSON encoded value',
|
||||
JSON.stringify(PANEL_TYPES.TABLE),
|
||||
PANEL_TYPES.TABLE,
|
||||
],
|
||||
['a plain string value', PANEL_TYPES.TABLE, PANEL_TYPES.TABLE],
|
||||
['a malformed value', '{foo', null],
|
||||
])('seeds from %s', (_, value, expected) => {
|
||||
expect(renderWithPanelTypes(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
|
||||
|
||||
@@ -19,4 +20,14 @@ describe('buildExplorerNavigationUrl', () => {
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
|
||||
);
|
||||
|
||||
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,15 @@ describe('readPanelTypeFromUrl', () => {
|
||||
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.LIST);
|
||||
});
|
||||
|
||||
it('returns the raw value instead of throwing on a malformed one', () => {
|
||||
withParam('{list');
|
||||
it.each(['{list', JSON.stringify('nope')])(
|
||||
'returns the fallback for %s, which is not a panel type',
|
||||
(value) => {
|
||||
withParam(value);
|
||||
|
||||
expect(readPanelTypeFromUrl()).toBe('{list');
|
||||
});
|
||||
expect(readPanelTypeFromUrl(PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
|
||||
expect(readPanelTypeFromUrl()).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the fallback when the param is absent', () => {
|
||||
withParam();
|
||||
|
||||
18
frontend/src/utils/parsePanelType.ts
Normal file
18
frontend/src/utils/parsePanelType.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
const PANEL_TYPE_VALUES = new Set<string>(Object.values(PANEL_TYPES));
|
||||
|
||||
// The param is JSON encoded, but older alert links and bookmarks carry it as a plain string.
|
||||
export const parsePanelType = (value: string): PANEL_TYPES | null => {
|
||||
let parsed: unknown = value;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
return typeof parsed === 'string' && PANEL_TYPE_VALUES.has(parsed)
|
||||
? (parsed as PANEL_TYPES)
|
||||
: null;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { getUnstableCurrentSearchParams } from './getUnstableCurrentSearchParams';
|
||||
import { parsePanelType } from './parsePanelType';
|
||||
|
||||
// The param is JSON-encoded, but a hand-written link can carry it unencoded.
|
||||
export const readPanelTypeFromUrl = (
|
||||
@@ -9,13 +10,5 @@ export const readPanelTypeFromUrl = (
|
||||
): PANEL_TYPES | null => {
|
||||
const raw = getUnstableCurrentSearchParams().get(QueryParams.panelTypes);
|
||||
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as PANEL_TYPES;
|
||||
} catch {
|
||||
return raw as PANEL_TYPES;
|
||||
}
|
||||
return (raw ? parsePanelType(raw) : null) ?? fallback;
|
||||
};
|
||||
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -22,12 +26,14 @@ import (
|
||||
const unmappedModelsLookback = time.Hour
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier}
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
@@ -51,13 +57,47 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
|
||||
unmapped = append(unmapped, m)
|
||||
return llmpricingruletypes.UnmappedModels(models, rules), nil
|
||||
}
|
||||
|
||||
// Collect keeps the rule counts even when ClickHouse is unreachable: the
|
||||
// counts that depend on it are skipped so the reporter retains their last value.
|
||||
// Models come from the traces tag table rather than a span scan, so the
|
||||
// unmapped count covers the metadata lookback instead of the last hour.
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := llmpricingruletypes.NewStatsFromRules(rules)
|
||||
|
||||
selectors := aiobservabilitytypes.StatsFieldValueSelectors(time.Now())
|
||||
results := make([]*telemetrytypes.TelemetryFieldValues, len(selectors))
|
||||
var lookups sync.WaitGroup
|
||||
for i, selector := range selectors {
|
||||
lookups.Go(func() {
|
||||
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
|
||||
if err != nil {
|
||||
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
|
||||
return
|
||||
}
|
||||
results[i] = fieldValues
|
||||
})
|
||||
}
|
||||
lookups.Wait()
|
||||
|
||||
values := make(map[string]*telemetrytypes.TelemetryFieldValues, len(selectors))
|
||||
for i, selector := range selectors {
|
||||
if results[i] != nil {
|
||||
values[selector.Name] = results[i]
|
||||
}
|
||||
}
|
||||
return unmapped, nil
|
||||
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
|
||||
if models := values[aiobservabilitytypes.GenAIRequestModel]; models != nil {
|
||||
maps.Copy(stats, llmpricingruletypes.NewStatsFromModels(models.StringValues, rules))
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -13,6 +14,8 @@ type Module interface {
|
||||
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
|
||||
agentConf.AgentFeature
|
||||
|
||||
statsreporter.StatsCollector
|
||||
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
|
||||
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
|
||||
|
||||
@@ -164,7 +164,7 @@ func NewModules(
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +572,7 @@ func New(
|
||||
cloudIntegrationModule,
|
||||
modules.LogsPipeline,
|
||||
modules.InfraMonitoring,
|
||||
modules.LLMPricingRule,
|
||||
querier,
|
||||
authz,
|
||||
}
|
||||
|
||||
55
pkg/types/aiobservabilitytypes/stats.go
Normal file
55
pkg/types/aiobservabilitytypes/stats.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
// statsLookback reaches into the previous day; the metadata store rounds
|
||||
// the start down to a day boundary.
|
||||
statsLookback = 24 * time.Hour
|
||||
// statsModelValuesLimit caps the model list, which also feeds the
|
||||
// unmapped model count. One value is enough for the other keys.
|
||||
statsModelValuesLimit = 100
|
||||
)
|
||||
|
||||
// statsAttributeKeys are the gen_ai span attributes whose recent presence the
|
||||
// stats reporter tracks under telemetry.traces.<key>.exists.
|
||||
var statsAttributeKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
|
||||
|
||||
// StatsFieldValueSelectors look up each tracked attribute's string values
|
||||
// since the start of the previous day.
|
||||
func StatsFieldValueSelectors(now time.Time) []*telemetrytypes.FieldValueSelector {
|
||||
selectors := make([]*telemetrytypes.FieldValueSelector, 0, len(statsAttributeKeys))
|
||||
for _, key := range statsAttributeKeys {
|
||||
limit := 1
|
||||
if key == GenAIRequestModel {
|
||||
limit = statsModelValuesLimit
|
||||
}
|
||||
selectors = append(selectors, &telemetrytypes.FieldValueSelector{
|
||||
FieldKeySelector: &telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: now.Add(-statsLookback).UnixMilli(),
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Name: key,
|
||||
},
|
||||
Limit: limit,
|
||||
})
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// NewStatsFromFieldValues reports whether each looked-up key had a value. Keys
|
||||
// absent from values are left out, so a failed lookup never reports false.
|
||||
func NewStatsFromFieldValues(values map[string]*telemetrytypes.TelemetryFieldValues) map[string]any {
|
||||
stats := make(map[string]any, len(values))
|
||||
for _, key := range statsAttributeKeys {
|
||||
if fieldValues := values[key]; fieldValues != nil {
|
||||
stats["telemetry.traces."+key+".exists"] = len(fieldValues.StringValues) > 0
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
73
pkg/types/aiobservabilitytypes/stats_test.go
Normal file
73
pkg/types/aiobservabilitytypes/stats_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func TestNewStatsFromFieldValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
values map[string]*telemetrytypes.TelemetryFieldValues
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "NoLookups_NothingReported",
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "EveryKeySeen_AllTrue",
|
||||
values: map[string]*telemetrytypes.TelemetryFieldValues{
|
||||
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
|
||||
GenAIToolName: {StringValues: []string{"search"}},
|
||||
GenAIAgentName: {StringValues: []string{"planner"}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"telemetry.traces.gen_ai.request.model.exists": true,
|
||||
"telemetry.traces.gen_ai.tool.name.exists": true,
|
||||
"telemetry.traces.gen_ai.agent.name.exists": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyAndMissingLookups_OnlyLookedUpKeysReported",
|
||||
values: map[string]*telemetrytypes.TelemetryFieldValues{
|
||||
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
|
||||
GenAIToolName: {},
|
||||
GenAIOperationName: {StringValues: []string{"chat"}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"telemetry.traces.gen_ai.request.model.exists": true,
|
||||
"telemetry.traces.gen_ai.tool.name.exists": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, NewStatsFromFieldValues(testCase.values))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsFieldValueSelectors(t *testing.T) {
|
||||
now := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
selectors := StatsFieldValueSelectors(now)
|
||||
|
||||
require.Len(t, selectors, 3)
|
||||
for _, selector := range selectors {
|
||||
assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), selector.StartUnixMilli, selector.Name)
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, selector.Signal, selector.Name)
|
||||
assert.Equal(t, telemetrytypes.FieldContextAttribute, selector.FieldContext, selector.Name)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeString, selector.FieldDataType, selector.Name)
|
||||
}
|
||||
assert.Equal(t, GenAIRequestModel, selectors[0].Name)
|
||||
assert.Equal(t, 100, selectors[0].Limit)
|
||||
assert.Equal(t, 1, selectors[1].Limit)
|
||||
assert.Equal(t, 1, selectors[2].Limit)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type CloudProviderType struct{ valuer.String }
|
||||
const foundryScriptURL = "https://signoz.io/foundry.sh"
|
||||
|
||||
var (
|
||||
// cloud providers.
|
||||
@@ -14,8 +17,16 @@ var (
|
||||
CloudProviderTypeGCP = CloudProviderType{valuer.NewString("gcp")}
|
||||
|
||||
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
|
||||
|
||||
foundryTemplateFuncs = template.FuncMap{
|
||||
"shellQuote": func(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type CloudProviderType struct{ valuer.String }
|
||||
|
||||
func NewCloudProvider(provider string) (CloudProviderType, error) {
|
||||
switch provider {
|
||||
case CloudProviderTypeAWS.StringValue():
|
||||
@@ -28,3 +39,11 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
|
||||
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
type foundryTemplateData struct {
|
||||
FoundryScriptURL string
|
||||
Region string
|
||||
IntegrationAccountID string
|
||||
AgentVersion string
|
||||
Credentials *Credentials
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
CloudFormationQuickCreateBaseURL = valuer.NewString("https://%s.console.aws.amazon.com/cloudformation/home")
|
||||
AgentCloudFormationTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json")
|
||||
AgentCloudFormationBaseStackName = valuer.NewString("signoz-integration")
|
||||
)
|
||||
var awsCLITemplate = template.Must(template.New("awsCLI").Funcs(foundryTemplateFuncs).Parse(awsCLITemplateStr()))
|
||||
|
||||
type AWSPostableAccountConfig struct {
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
@@ -16,7 +15,7 @@ type AWSPostableAccountConfig struct {
|
||||
}
|
||||
|
||||
type AWSConnectionArtifact struct {
|
||||
ConnectionURL string `json:"connectionUrl" required:"true"`
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
}
|
||||
|
||||
type AWSAccountConfig struct {
|
||||
@@ -105,10 +104,23 @@ type AWSServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func NewAWSConnectionArtifact(connectionURL string) *AWSConnectionArtifact {
|
||||
return &AWSConnectionArtifact{
|
||||
ConnectionURL: connectionURL,
|
||||
func NewAWSConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, deploymentRegion string) (*AWSConnectionArtifact, error) {
|
||||
data := foundryTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
Region: deploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
var command bytes.Buffer
|
||||
if err := awsCLITemplate.Execute(&command, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AWSConnectionArtifact{
|
||||
CLICommand: command.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewAWSIntegrationConfig(enabledRegions []string, telemetryCollectionStrategy *AWSTelemetryCollectionStrategy) *AWSIntegrationConfig {
|
||||
@@ -167,3 +179,14 @@ func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfi
|
||||
older.Telemetry = oldTelemetry
|
||||
return older
|
||||
}
|
||||
|
||||
func awsCLITemplateStr() string {
|
||||
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration aws cloudformation \
|
||||
--region {{.Region | 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}}`
|
||||
}
|
||||
|
||||
@@ -2,37 +2,12 @@ package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
AgentArmTemplateStorePath = "https://signoz-integrations.s3.us-east-1.amazonaws.com/azure-arm-template-%s.json"
|
||||
AgentDeploymentStackName = "signoz-integration"
|
||||
|
||||
// Default values for fixed ARM template parameters.
|
||||
armDefaultRgName = "signoz-integration-rg"
|
||||
armDefaultContainerEnvName = "signoz-integration-agent-env"
|
||||
armDefaultDeploymentEnv = "production"
|
||||
|
||||
// ARM template parameter key names used in both CLI and PowerShell deployment commands.
|
||||
armParamLocation = "location"
|
||||
armParamSignozAPIKey = "signozApiKey"
|
||||
armParamSignozAPIUrl = "signozApiUrl"
|
||||
armParamSignozIngestionURL = "signozIngestionUrl"
|
||||
armParamSignozIngestionKey = "signozIngestionKey"
|
||||
armParamAccountID = "signozIntegrationAccountId"
|
||||
armParamAgentVersion = "signozIntegrationAgentVersion"
|
||||
armParamRgName = "rgName"
|
||||
armParamContainerEnvName = "containerEnvName"
|
||||
armParamDeploymentEnv = "deploymentEnv"
|
||||
|
||||
// command templates.
|
||||
azureCLITemplate = template.Must(template.New("azureCLI").Parse(azureCLITemplateStr()))
|
||||
azurePowerShellTemplate = template.Must(template.New("azurePS").Parse(azurePowerShellTemplateStr()))
|
||||
)
|
||||
var azureCLITemplate = template.Must(template.New("azureCLI").Funcs(foundryTemplateFuncs).Parse(azureCLITemplateStr()))
|
||||
|
||||
type AzureAccountConfig struct {
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
@@ -46,8 +21,7 @@ type UpdatableAzureAccountConfig struct {
|
||||
type AzurePostableAccountConfig = AzureAccountConfig
|
||||
|
||||
type AzureConnectionArtifact struct {
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
CloudPowerShellCommand string `json:"cloudPowerShellCommand" required:"true"`
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
}
|
||||
|
||||
type AzureServiceConfig struct {
|
||||
@@ -85,36 +59,6 @@ type AzureIntegrationConfig struct {
|
||||
TelemetryCollectionStrategy []*AzureTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// azureTemplateData is the data struct passed to both command templates.
|
||||
// All fields are exported so text/template can access them.
|
||||
type azureTemplateData struct {
|
||||
// Deploy parameter values.
|
||||
TemplateURL string
|
||||
Location string
|
||||
SignozAPIKey string
|
||||
SignozAPIUrl string
|
||||
SignozIngestionURL string
|
||||
SignozIngestionKey string
|
||||
AccountID string
|
||||
AgentVersion string
|
||||
// ARM parameter key names (from package-level vars).
|
||||
StackName string
|
||||
ParamLocation string
|
||||
ParamSignozAPIKey string
|
||||
ParamSignozAPIUrl string
|
||||
ParamSignozIngestionURL string
|
||||
ParamSignozIngestionKey string
|
||||
ParamAccountID string
|
||||
ParamAgentVersion string
|
||||
ParamRgName string
|
||||
ParamContainerEnvName string
|
||||
ParamDeploymentEnv string
|
||||
// Fixed default values.
|
||||
DefaultRgName string
|
||||
DefaultContainerEnvName string
|
||||
DefaultDeploymentEnv string
|
||||
}
|
||||
|
||||
func NewAzureIntegrationConfig(
|
||||
deploymentRegion string,
|
||||
resourceGroups []string,
|
||||
@@ -127,104 +71,32 @@ func NewAzureIntegrationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
func NewAzureConnectionArtifact(
|
||||
accountID valuer.UUID,
|
||||
agentVersion string,
|
||||
creds *Credentials,
|
||||
cfg *AzurePostableAccountConfig,
|
||||
) (*AzureConnectionArtifact, error) {
|
||||
data := azureTemplateData{
|
||||
TemplateURL: fmt.Sprintf(AgentArmTemplateStorePath, agentVersion),
|
||||
Location: cfg.DeploymentRegion,
|
||||
SignozAPIKey: creds.SigNozAPIKey,
|
||||
SignozAPIUrl: creds.SigNozAPIURL,
|
||||
SignozIngestionURL: creds.IngestionURL,
|
||||
SignozIngestionKey: creds.IngestionKey,
|
||||
AccountID: accountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
StackName: AgentDeploymentStackName,
|
||||
ParamLocation: armParamLocation,
|
||||
ParamSignozAPIKey: armParamSignozAPIKey,
|
||||
ParamSignozAPIUrl: armParamSignozAPIUrl,
|
||||
ParamSignozIngestionURL: armParamSignozIngestionURL,
|
||||
ParamSignozIngestionKey: armParamSignozIngestionKey,
|
||||
ParamAccountID: armParamAccountID,
|
||||
ParamAgentVersion: armParamAgentVersion,
|
||||
ParamRgName: armParamRgName,
|
||||
ParamContainerEnvName: armParamContainerEnvName,
|
||||
ParamDeploymentEnv: armParamDeploymentEnv,
|
||||
DefaultRgName: armDefaultRgName,
|
||||
DefaultContainerEnvName: armDefaultContainerEnvName,
|
||||
DefaultDeploymentEnv: armDefaultDeploymentEnv,
|
||||
func NewAzureConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *AzurePostableAccountConfig) (*AzureConnectionArtifact, error) {
|
||||
data := foundryTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
cliCommand, err := newAzureConnectionCLICommand(data)
|
||||
if err != nil {
|
||||
var command bytes.Buffer
|
||||
if err := azureCLITemplate.Execute(&command, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
psCommand, err := newAzureConnectionPowerShellCommand(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AzureConnectionArtifact{
|
||||
CLICommand: cliCommand,
|
||||
CloudPowerShellCommand: psCommand,
|
||||
CLICommand: command.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newAzureConnectionCLICommand(data azureTemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := azureCLITemplate.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func newAzureConnectionPowerShellCommand(data azureTemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := azurePowerShellTemplate.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func azureCLITemplateStr() string {
|
||||
return `az stack sub create \
|
||||
--name {{.StackName}} \
|
||||
--location {{.Location}} \
|
||||
--template-uri {{.TemplateURL}} \
|
||||
--parameters \
|
||||
{{.ParamLocation}}='{{.Location}}' \
|
||||
{{.ParamSignozAPIKey}}='{{.SignozAPIKey}}' \
|
||||
{{.ParamSignozAPIUrl}}='{{.SignozAPIUrl}}' \
|
||||
{{.ParamSignozIngestionURL}}='{{.SignozIngestionURL}}' \
|
||||
{{.ParamSignozIngestionKey}}='{{.SignozIngestionKey}}' \
|
||||
{{.ParamAccountID}}='{{.AccountID}}' \
|
||||
{{.ParamAgentVersion}}='{{.AgentVersion}}' \
|
||||
--action-on-unmanage deleteAll \
|
||||
--deny-settings-mode denyDelete`
|
||||
}
|
||||
|
||||
func azurePowerShellTemplateStr() string {
|
||||
return "New-AzSubscriptionDeploymentStack `\n" +
|
||||
" -Name \"{{.StackName}}\" `\n" +
|
||||
" -Location \"{{.Location}}\" `\n" +
|
||||
" -TemplateUri \"{{.TemplateURL}}\" `\n" +
|
||||
" -TemplateParameterObject @{\n" +
|
||||
" {{.ParamLocation}} = \"{{.Location}}\"\n" +
|
||||
" {{.ParamSignozAPIKey}} = \"{{.SignozAPIKey}}\"\n" +
|
||||
" {{.ParamSignozAPIUrl}} = \"{{.SignozAPIUrl}}\"\n" +
|
||||
" {{.ParamSignozIngestionURL}} = \"{{.SignozIngestionURL}}\"\n" +
|
||||
" {{.ParamSignozIngestionKey}} = \"{{.SignozIngestionKey}}\"\n" +
|
||||
" {{.ParamAccountID}} = \"{{.AccountID}}\"\n" +
|
||||
" {{.ParamAgentVersion}} = \"{{.AgentVersion}}\"\n" +
|
||||
" {{.ParamRgName}} = \"{{.DefaultRgName}}\"\n" +
|
||||
" {{.ParamContainerEnvName}} = \"{{.DefaultContainerEnvName}}\"\n" +
|
||||
" {{.ParamDeploymentEnv}} = \"{{.DefaultDeploymentEnv}}\"\n" +
|
||||
" } `\n" +
|
||||
" -ActionOnUnmanage \"deleteAll\" `\n" +
|
||||
" -DenySettingsMode \"denyDelete\""
|
||||
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration azure bicep \
|
||||
--region {{.Region | 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}}`
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// file is generated by AI
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var testTemplateData = azureTemplateData{
|
||||
TemplateURL: fmt.Sprintf(AgentArmTemplateStorePath, "v0.1.0"),
|
||||
Location: "eastus",
|
||||
SignozAPIKey: "test-api-key",
|
||||
SignozAPIUrl: "https://signoz.example.com",
|
||||
SignozIngestionURL: "https://ingest.example.com",
|
||||
SignozIngestionKey: "test-ingest-key",
|
||||
AccountID: "acct-123",
|
||||
AgentVersion: "v0.1.0",
|
||||
StackName: AgentDeploymentStackName,
|
||||
ParamLocation: armParamLocation,
|
||||
ParamSignozAPIKey: armParamSignozAPIKey,
|
||||
ParamSignozAPIUrl: armParamSignozAPIUrl,
|
||||
ParamSignozIngestionURL: armParamSignozIngestionURL,
|
||||
ParamSignozIngestionKey: armParamSignozIngestionKey,
|
||||
ParamAccountID: armParamAccountID,
|
||||
ParamAgentVersion: armParamAgentVersion,
|
||||
ParamRgName: armParamRgName,
|
||||
ParamContainerEnvName: armParamContainerEnvName,
|
||||
ParamDeploymentEnv: armParamDeploymentEnv,
|
||||
DefaultRgName: armDefaultRgName,
|
||||
DefaultContainerEnvName: armDefaultContainerEnvName,
|
||||
DefaultDeploymentEnv: armDefaultDeploymentEnv,
|
||||
}
|
||||
|
||||
func TestNewAzureConnectionCLICommand(t *testing.T) {
|
||||
cmd, err := newAzureConnectionCLICommand(testTemplateData)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"az stack sub create",
|
||||
fmt.Sprintf("--name %s", AgentDeploymentStackName),
|
||||
fmt.Sprintf("--location %s", testTemplateData.Location),
|
||||
fmt.Sprintf("--template-uri %s", testTemplateData.TemplateURL),
|
||||
fmt.Sprintf("%s='%s'", armParamLocation, testTemplateData.Location),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozAPIKey, testTemplateData.SignozAPIKey),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozAPIUrl, testTemplateData.SignozAPIUrl),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozIngestionURL, testTemplateData.SignozIngestionURL),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozIngestionKey, testTemplateData.SignozIngestionKey),
|
||||
fmt.Sprintf("%s='%s'", armParamAccountID, testTemplateData.AccountID),
|
||||
fmt.Sprintf("%s='%s'", armParamAgentVersion, testTemplateData.AgentVersion),
|
||||
"--action-on-unmanage deleteAll",
|
||||
"--deny-settings-mode denyDelete",
|
||||
}
|
||||
|
||||
for _, fragment := range mustContain {
|
||||
if !strings.Contains(cmd, fragment) {
|
||||
t.Errorf("CLI command missing %q\ngot:\n%s", fragment, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Lines must be joined with the bash line-continuation separator.
|
||||
if !strings.Contains(cmd, " \\\n") {
|
||||
t.Errorf("CLI command missing line-continuation separator ' \\\\\\n'\ngot:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAzureConnectionPowerShellCommand(t *testing.T) {
|
||||
cmd, err := newAzureConnectionPowerShellCommand(testTemplateData)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"New-AzSubscriptionDeploymentStack",
|
||||
fmt.Sprintf("-Name \"%s\"", AgentDeploymentStackName),
|
||||
fmt.Sprintf("-Location \"%s\"", testTemplateData.Location),
|
||||
fmt.Sprintf("-TemplateUri \"%s\"", testTemplateData.TemplateURL),
|
||||
armParamLocation,
|
||||
armParamSignozAPIKey,
|
||||
armParamSignozAPIUrl,
|
||||
armParamSignozIngestionURL,
|
||||
armParamSignozIngestionKey,
|
||||
armParamAccountID,
|
||||
armParamAgentVersion,
|
||||
armParamRgName,
|
||||
armParamContainerEnvName,
|
||||
armParamDeploymentEnv,
|
||||
armDefaultRgName,
|
||||
armDefaultContainerEnvName,
|
||||
armDefaultDeploymentEnv,
|
||||
"-ActionOnUnmanage \"deleteAll\"",
|
||||
"-DenySettingsMode \"denyDelete\"",
|
||||
}
|
||||
|
||||
for _, fragment := range mustContain {
|
||||
if !strings.Contains(cmd, fragment) {
|
||||
t.Errorf("PowerShell command missing %q\ngot:\n%s", fragment, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Final command must not end with a line-continuation backtick.
|
||||
if strings.HasSuffix(strings.TrimSpace(cmd), "`") {
|
||||
t.Errorf("PowerShell command must not end with a backtick\ngot:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAzureConnectionArtifact(t *testing.T) {
|
||||
accountID := valuer.GenerateUUID()
|
||||
agentVersion := "v0.1.0"
|
||||
creds := &Credentials{
|
||||
SigNozAPIURL: "https://signoz.example.com",
|
||||
SigNozAPIKey: "test-api-key",
|
||||
IngestionURL: "https://ingest.example.com",
|
||||
IngestionKey: "test-ingest-key",
|
||||
}
|
||||
cfg := &AzurePostableAccountConfig{
|
||||
DeploymentRegion: "eastus",
|
||||
ResourceGroups: []string{"rg1"},
|
||||
}
|
||||
|
||||
artifact, err := NewAzureConnectionArtifact(accountID, agentVersion, creds, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if artifact.CLICommand == "" {
|
||||
t.Error("CLICommand must not be empty")
|
||||
}
|
||||
if artifact.CloudPowerShellCommand == "" {
|
||||
t.Error("CloudPowerShellCommand must not be empty")
|
||||
}
|
||||
if !strings.Contains(artifact.CLICommand, accountID.StringValue()) {
|
||||
t.Errorf("CLICommand must contain accountID %q", accountID.StringValue())
|
||||
}
|
||||
if !strings.Contains(artifact.CloudPowerShellCommand, accountID.StringValue()) {
|
||||
t.Errorf("CloudPowerShellCommand must contain accountID %q", accountID.StringValue())
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,7 @@ 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()))
|
||||
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(foundryTemplateFuncs).Parse(gcpCLITemplateStr()))
|
||||
)
|
||||
|
||||
type GCPConnectionMethod struct{ valuer.String }
|
||||
@@ -112,12 +106,14 @@ func (config *GCPAccountConfig) ErrIfInvalid() error {
|
||||
|
||||
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,
|
||||
foundryTemplateData: foundryTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
},
|
||||
ProjectID: cfg.DeploymentProjectID,
|
||||
}
|
||||
|
||||
var command bytes.Buffer
|
||||
@@ -158,12 +154,8 @@ func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters
|
||||
}
|
||||
|
||||
type gcpTemplateData struct {
|
||||
FoundryScriptURL string
|
||||
ProjectID string
|
||||
Region string
|
||||
IntegrationAccountID string
|
||||
AgentVersion string
|
||||
Credentials *Credentials
|
||||
foundryTemplateData
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
func gcpCLITemplateStr() string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -10,6 +11,7 @@ const (
|
||||
statKeyPanelTracesCount = "dashboard.panels.traces.count"
|
||||
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
|
||||
statKeyPanelLogsCount = "dashboard.panels.logs.count"
|
||||
statKeyPanelAICount = "dashboard.panels.ai.count"
|
||||
)
|
||||
|
||||
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
|
||||
@@ -22,14 +24,17 @@ var panelSignalStatKeys = map[telemetrytypes.Signal]string{
|
||||
}
|
||||
|
||||
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
|
||||
// do not decode as v2 contribute to dashboard.count only.
|
||||
// do not decode as v2 contribute to dashboard.count only. System dashboards are
|
||||
// provisioned into every org, so they are left out of every count.
|
||||
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
|
||||
stats := newPanelStats()
|
||||
stats := newStats()
|
||||
|
||||
for _, dashboard := range dashboards {
|
||||
if dashboard == nil {
|
||||
if dashboard == nil || dashboard.Source == SourceSystem {
|
||||
continue
|
||||
}
|
||||
incrementStat(stats, statKeyDashboardCount)
|
||||
|
||||
dashboardV2, err := dashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -37,7 +42,6 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
|
||||
addPanelStats(&dashboardV2.Spec, stats)
|
||||
}
|
||||
|
||||
stats[statKeyDashboardCount] = int64(len(dashboards))
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -45,24 +49,26 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
|
||||
// created, straight off the postable spec — the create path has no reason to make
|
||||
// a storable round-trip just to be counted.
|
||||
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
|
||||
stats := newPanelStats()
|
||||
stats := newStats()
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
addPanelStats(&postable.Spec, stats)
|
||||
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
return stats
|
||||
}
|
||||
|
||||
func newPanelStats() map[string]any {
|
||||
func newStats() map[string]any {
|
||||
return map[string]any{
|
||||
statKeyDashboardCount: int64(0),
|
||||
statKeyPanelCount: int64(0),
|
||||
statKeyPanelTracesCount: int64(0),
|
||||
statKeyPanelMetricsCount: int64(0),
|
||||
statKeyPanelLogsCount: int64(0),
|
||||
statKeyPanelAICount: int64(0),
|
||||
}
|
||||
}
|
||||
|
||||
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
|
||||
// the signal they read.
|
||||
// the signal they read. AI queries count only towards the AI stat.
|
||||
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
for _, panel := range spec.Panels {
|
||||
if panel == nil {
|
||||
@@ -76,6 +82,10 @@ func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
continue
|
||||
}
|
||||
for _, envelope := range composite.Queries {
|
||||
if envelope.Type == qb.QueryTypeBuilderAI {
|
||||
incrementStat(stats, statKeyPanelAICount)
|
||||
continue
|
||||
}
|
||||
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
|
||||
incrementStat(stats, key)
|
||||
}
|
||||
|
||||
@@ -117,20 +117,60 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// An AI builder query is always a traces query, so it counts towards traces.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
aiBuilder := `{
|
||||
func statsAIBuilderQuery() string {
|
||||
return `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
|
||||
}
|
||||
|
||||
// An AI builder query reads traces but counts only towards the AI stat.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsCountsAICompositeSubQueries(t *testing.T) {
|
||||
composite := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_ai_query", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
|
||||
]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `
|
||||
"p1": `+statsPanel(composite)+`,
|
||||
"p2": `+statsPanel(statsAIBuilderQuery())+`
|
||||
`)
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelAICount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsSkipsSystemDashboards(t *testing.T) {
|
||||
system := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
system.Source = SourceSystem
|
||||
system.Name = SystemDashboardNamePrefix + "overview"
|
||||
user := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{system, user})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// promql and clickhouse queries carry no signal, so they land in the panel total
|
||||
@@ -215,6 +255,17 @@ func TestNewStatsFromPostableDashboardV2(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2CountsAIBuilderQueries(t *testing.T) {
|
||||
postable := newStatsPostableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
|
||||
stats := NewStatsFromPostableDashboardV2(postable)
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
|
||||
@@ -233,4 +284,5 @@ func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
@@ -277,3 +277,13 @@ func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UnmappedModels(models []*UnmappedModel, rules []*LLMPricingRule) []*UnmappedModel {
|
||||
unmapped := make([]*UnmappedModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if !ModelMatchesAnyRule(model.ModelName, rules) {
|
||||
unmapped = append(unmapped, model)
|
||||
}
|
||||
}
|
||||
return unmapped
|
||||
}
|
||||
|
||||
31
pkg/types/llmpricingruletypes/stats.go
Normal file
31
pkg/types/llmpricingruletypes/stats.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
// NewStatsFromRules reports the rules users added themselves and the synced
|
||||
// defaults users pinned.
|
||||
func NewStatsFromRules(rules []*LLMPricingRule) map[string]any {
|
||||
custom, overrides := int64(0), int64(0)
|
||||
for _, rule := range rules {
|
||||
switch {
|
||||
case rule.SourceID == nil:
|
||||
custom++
|
||||
case rule.IsOverride:
|
||||
overrides++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"llmpricingrule.custom.count": custom,
|
||||
"llmpricingrule.override.count": overrides,
|
||||
}
|
||||
}
|
||||
|
||||
func NewStatsFromModels(models []string, rules []*LLMPricingRule) map[string]any {
|
||||
unmapped := int64(0)
|
||||
for _, model := range models {
|
||||
if !ModelMatchesAnyRule(model, rules) {
|
||||
unmapped++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{"llmpricingrule.unmapped_model.count": unmapped}
|
||||
}
|
||||
65
pkg/types/llmpricingruletypes/stats_test.go
Normal file
65
pkg/types/llmpricingruletypes/stats_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func TestNewStatsFromRules(t *testing.T) {
|
||||
sourceID := valuer.GenerateUUID()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
rules []*LLMPricingRule
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "NoRules",
|
||||
want: map[string]any{
|
||||
"llmpricingrule.custom.count": int64(0),
|
||||
"llmpricingrule.override.count": int64(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CustomPinnedAndSyncedRules_CountedSeparately",
|
||||
rules: []*LLMPricingRule{
|
||||
{IsOverride: true},
|
||||
{IsOverride: false},
|
||||
{SourceID: &sourceID, IsOverride: true},
|
||||
{SourceID: &sourceID, IsOverride: false},
|
||||
},
|
||||
want: map[string]any{
|
||||
"llmpricingrule.custom.count": int64(2),
|
||||
"llmpricingrule.override.count": int64(1),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, NewStatsFromRules(testCase.rules))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStatsFromModels(t *testing.T) {
|
||||
rules := []*LLMPricingRule{{ModelPattern: StringSlice{"gpt-*"}}}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
models []string
|
||||
want int64
|
||||
}{
|
||||
{name: "NoModels", want: 0},
|
||||
{name: "PricedAndUnpricedModels_UnpricedCounted", models: []string{"gpt-5-nano", "claude-haiku-5-5", "gemini-3"}, want: 2},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, map[string]any{"llmpricingrule.unmapped_model.count": testCase.want}, NewStatsFromModels(testCase.models, rules))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -259,3 +259,20 @@ neither antd 5.11 nor `@rc-component/trigger` closes it on Escape, so
|
||||
it, but when the popover covered the Advanced Options header, that click waited
|
||||
on "intercepts pointer events" for the rest of the budget. The driver now closes
|
||||
the popover through its trigger and waits for it to go.
|
||||
|
||||
## dnd-kit stops clicks right after a drop
|
||||
|
||||
`app-shell/sidenav` TC-03 needed a retry in half its CI jobs and failed all
|
||||
three attempts in four of 22. dnd-kit 6.1 adds a capture-phase click handler on
|
||||
`document` that stops propagation once a drag activates, and removes it 50 ms
|
||||
after the drop. `saveShortcuts` clicked Save inside that window, so the modal
|
||||
stayed open and no preference request went out. It now clicks again while the
|
||||
dialog is still open.
|
||||
|
||||
## The Kafka configuration button drops clicks while loading
|
||||
|
||||
`messaging-queues/kafka` TC-01 failed its first attempt in four of 20 CI jobs.
|
||||
The health check button passes `loading` to an antd 5.11 `Button` while the
|
||||
three onboarding status queries fetch, and a loading antd button ignores clicks
|
||||
without being disabled, so Playwright clicks it and nothing opens.
|
||||
`openHealthCheck` clicks again until the dialog shows.
|
||||
|
||||
37
tests/e2e/drivers/integrations.ts
generated
37
tests/e2e/drivers/integrations.ts
generated
@@ -210,15 +210,13 @@ export interface AwsFlow {
|
||||
toggleRegion(code: string): Promise<void>;
|
||||
/** Fill whichever credential inputs the credentials endpoint left empty. */
|
||||
fillMissingCredentials(): Promise<void>;
|
||||
/**
|
||||
* Submit the add-account drawer. `window.open` is replaced first, so no AWS
|
||||
* console tab opens; the URL it was called with is returned instead.
|
||||
*/
|
||||
launch(): Promise<{
|
||||
/** Submit the add-account drawer. Resolves the agent setup command it returned. */
|
||||
generateCommand(): Promise<{
|
||||
accountId: string;
|
||||
connectionUrl: string;
|
||||
command: string;
|
||||
body: { config?: { aws?: { regions?: string[] } } };
|
||||
}>;
|
||||
agentCommand(): Locator;
|
||||
/** Set exactly `codes` in the edit drawer and press Update Changes. */
|
||||
updateRegions(codes: string[]): Promise<Response>;
|
||||
}
|
||||
@@ -492,34 +490,23 @@ export function cloudDriver(page: Page, provider: CloudProvider): Cloud {
|
||||
}
|
||||
},
|
||||
|
||||
launch: async () => {
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
open: (url?: string) => null;
|
||||
__e2eOpened?: string;
|
||||
};
|
||||
w.open = (url?: string): null => {
|
||||
w.__e2eOpened = String(url ?? '');
|
||||
return null;
|
||||
};
|
||||
});
|
||||
generateCommand: async () => {
|
||||
const created = page.waitForResponse(isCall('POST', `${base}/accounts`));
|
||||
await page.getByTestId('aws-launch-cfn-btn').click();
|
||||
await page.getByTestId('aws-generate-command-btn').click();
|
||||
const response = await created;
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
const { data } = (await response.json()) as { data: { id: string } };
|
||||
const connectionUrl = await page
|
||||
.waitForFunction(
|
||||
() => (window as unknown as { __e2eOpened?: string }).__e2eOpened,
|
||||
)
|
||||
.then((handle) => handle.jsonValue() as Promise<string>);
|
||||
const { data } = (await response.json()) as {
|
||||
data: { id: string; connectionArtifact: { aws: { cliCommand: string } } };
|
||||
};
|
||||
return {
|
||||
accountId: data.id,
|
||||
connectionUrl,
|
||||
command: data.connectionArtifact.aws.cliCommand,
|
||||
body: response.request().postDataJSON(),
|
||||
};
|
||||
},
|
||||
|
||||
agentCommand: () => page.getByTestId('aws-agent-command'),
|
||||
|
||||
updateRegions: async (codes) => {
|
||||
const boxes = page.locator('[data-testid^="aws-region-"]');
|
||||
await expect(boxes.first()).toBeVisible();
|
||||
|
||||
10
tests/e2e/drivers/messaging-queues.ts
generated
10
tests/e2e/drivers/messaging-queues.ts
generated
@@ -258,9 +258,15 @@ export function messagingQueuesDriver(page: Page): MessagingQueues {
|
||||
|
||||
openHealthCheck: async () => {
|
||||
// "Configuration", or "Missing Configuration (N)" when checks fail.
|
||||
await page.getByRole('button', { name: /Configuration/ }).click();
|
||||
const button = page.getByRole('button', { name: /Configuration/ });
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
// The button drops clicks while its checks load, and is not disabled.
|
||||
await expect(async () => {
|
||||
if (!(await dialog.isVisible())) {
|
||||
await button.click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 });
|
||||
}).toPass();
|
||||
return dialog;
|
||||
},
|
||||
|
||||
|
||||
11
tests/e2e/drivers/shell.ts
generated
11
tests/e2e/drivers/shell.ts
generated
@@ -635,7 +635,16 @@ export function shellDriver(page: Page): Shell {
|
||||
|
||||
saveShortcuts: async () => {
|
||||
const saved = page.waitForResponse(isPreferencePut('nav_shortcuts'));
|
||||
await page.getByTestId('save-changes-btn').click();
|
||||
const dialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Manage Shortcuts' });
|
||||
// dnd-kit stops every click for 50 ms after a drop.
|
||||
await expect(async () => {
|
||||
if (await dialog.isVisible()) {
|
||||
await page.getByTestId('save-changes-btn').click({ timeout: 5_000 });
|
||||
}
|
||||
await expect(dialog).toBeHidden({ timeout: 1_000 });
|
||||
}).toPass();
|
||||
const response = await saved;
|
||||
expect(response.ok()).toBe(true);
|
||||
return (response.request().postDataJSON() as { value: string[] }).value;
|
||||
|
||||
@@ -98,15 +98,12 @@ test.describe('Cloud integrations', () => {
|
||||
await aws.aws.toggleRegion('us-east-1');
|
||||
await aws.aws.fillMissingCredentials();
|
||||
|
||||
const launched = await aws.aws.launch();
|
||||
accountId = launched.accountId;
|
||||
expect(launched.body.config?.aws?.regions).toEqual(['us-east-1']);
|
||||
expect(launched.connectionUrl).toContain(
|
||||
'console.aws.amazon.com/cloudformation',
|
||||
);
|
||||
expect(launched.connectionUrl).toContain(
|
||||
`param_SigNozAccountId=${accountId}`,
|
||||
);
|
||||
const generated = await aws.aws.generateCommand();
|
||||
accountId = generated.accountId;
|
||||
expect(generated.body.config?.aws?.regions).toEqual(['us-east-1']);
|
||||
expect(generated.command).toContain('cloudintegration aws cloudformation');
|
||||
expect(generated.command).toContain(accountId);
|
||||
await expect(aws.aws.agentCommand()).toContainText(accountId);
|
||||
await expect(aws.waitingCallout()).toBeVisible();
|
||||
|
||||
await aws.checkIn(accountId, providerAccountId);
|
||||
|
||||
@@ -83,11 +83,12 @@ def test_create_account(
|
||||
|
||||
if spec.provider == "aws":
|
||||
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
|
||||
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
|
||||
assert "cliCommand" in artifact["aws"], "connectionArtifact.aws should contain 'cliCommand'"
|
||||
|
||||
connection_url = artifact["aws"]["connectionUrl"]
|
||||
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
|
||||
assert f"region={spec.initial_params['deployment_region']}" in connection_url, "connectionUrl should contain the deployment region"
|
||||
cli_command = artifact["aws"]["cliCommand"]
|
||||
assert "cloudintegration aws cloudformation" in cli_command, "cliCommand should run the foundry aws cloudformation flow"
|
||||
assert f"--region '{spec.initial_params['deployment_region']}'" in cli_command, "cliCommand should contain the deployment region"
|
||||
assert f"--integration-account-id '{data['id']}'" in cli_command, "cliCommand should contain the integration account id"
|
||||
else:
|
||||
# GCP is a manual flow: no one-click install artifact.
|
||||
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
|
||||
|
||||
Reference in New Issue
Block a user