mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-05 16:00:41 +00:00
Compare commits
13 Commits
feat/distr
...
nv/offset-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4813da9556 | ||
|
|
9d465d54f5 | ||
|
|
7d95fa7b25 | ||
|
|
cd1068e1c3 | ||
|
|
dca68d89bc | ||
|
|
1be806c58d | ||
|
|
8569702161 | ||
|
|
1b8451650a | ||
|
|
a5bfebbc29 | ||
|
|
afbe1ac7a6 | ||
|
|
2103d6c7b7 | ||
|
|
efb2100e69 | ||
|
|
ce44f1a9cf |
7
.github/workflows/e2eci.yaml
vendored
7
.github/workflows/e2eci.yaml
vendored
@@ -2,6 +2,9 @@ name: e2eci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -13,8 +16,12 @@ jobs:
|
||||
outputs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
steps:
|
||||
- name: merge-queue-pass
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "::notice::Merge queue run. E2E already gated the PR, so the required e2e check auto-passes."
|
||||
- name: changes
|
||||
id: changes
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -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:
|
||||
@@ -4030,7 +4027,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -4067,10 +4066,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -4082,8 +4082,10 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10105,6 +10107,10 @@ components:
|
||||
description: The unique name of the formula, such as F1, used by selectedQueryName
|
||||
and by panels. Referencing a formula from another formula is not reliable.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of ordered series or rows to skip before the page
|
||||
starts.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the formula result.
|
||||
items:
|
||||
@@ -10130,7 +10136,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10166,10 +10174,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10181,8 +10190,10 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10244,7 +10255,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10281,10 +10294,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10296,8 +10310,10 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10359,7 +10375,9 @@ components:
|
||||
the query they draw.
|
||||
cursor:
|
||||
description: The opaque nextCursor value from a previous raw result, used
|
||||
to fetch the next page.
|
||||
to fetch the next page. Honored only by raw requests whose order starts
|
||||
with timestamp, for logs followed by id in the same direction; every other
|
||||
request ignores it.
|
||||
type: string
|
||||
disabled:
|
||||
description: When true, the result is excluded from the response; the query
|
||||
@@ -10396,10 +10414,11 @@ components:
|
||||
are replaced by group-by label values.
|
||||
type: string
|
||||
limit:
|
||||
description: The maximum number of rows for raw requests, or of series or
|
||||
rows for grouped aggregations, keeping the top entries by order. When
|
||||
0, aggregations are unlimited while raw requests ordered by timestamp
|
||||
return a page of 10000 rows; follow nextCursor for the rest.
|
||||
description: The maximum number of rows for raw and trace requests, or of
|
||||
series or rows for grouped scalar and time_series requests, keeping the
|
||||
top entries by order. When 0, scalar and time_series requests are unlimited,
|
||||
raw requests ordered by timestamp return a page of 10000 rows to continue
|
||||
with nextCursor, and other raw and trace requests return 100 rows.
|
||||
type: integer
|
||||
limitBy:
|
||||
allOf:
|
||||
@@ -10411,8 +10430,10 @@ components:
|
||||
operators and selectedQueryName refer to it.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of rows to skip before returning results. Prefer
|
||||
cursor for raw requests.
|
||||
description: The number of rows for raw and trace requests, or of ordered
|
||||
series or rows for scalar and time_series requests, to skip before the
|
||||
page starts. On raw requests the skip counts from where a cursor resumes,
|
||||
and the skipped rows are still read, so prefer cursor for paging.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the result. Order by a group-by field, an
|
||||
@@ -10513,8 +10534,8 @@ components:
|
||||
description: The unique name of the trace operator query.
|
||||
type: string
|
||||
offset:
|
||||
description: The number of raw results to skip before returning. Aggregation
|
||||
requests ignore it.
|
||||
description: The number of raw or trace rows, or of ordered series or rows
|
||||
for time_series and scalar requests, to skip before the page starts.
|
||||
type: integer
|
||||
order:
|
||||
description: The sort order of the matched traces; only span_count and trace_duration
|
||||
|
||||
@@ -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 {
|
||||
@@ -5277,7 +5273,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5310,7 +5306,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5324,7 +5320,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5669,7 +5665,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5702,7 +5698,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5716,7 +5712,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5849,7 +5845,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5882,7 +5878,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5896,7 +5892,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5948,7 +5944,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5981,7 +5977,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5995,7 +5991,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -7068,6 +7064,11 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of ordered series or rows to skip before the page starts.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The sort order of the formula result.
|
||||
@@ -7150,7 +7151,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
|
||||
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
frontend/src/components/OverflowBadges/ItemBadge.tsx
Normal file
17
frontend/src/components/OverflowBadges/ItemBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface ItemBadgeProps {
|
||||
children: ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ItemBadge({ children, testId }: ItemBadgeProps): JSX.Element {
|
||||
return (
|
||||
<Badge textTransform="none" variant="solid" color="secondary" testId={testId}>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemBadge;
|
||||
@@ -0,0 +1,32 @@
|
||||
.badges {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow {
|
||||
flex-shrink: 0;
|
||||
--badge-max-width: none;
|
||||
}
|
||||
|
||||
/* Lays every badge out at its natural width, off-screen, to size the visible row. */
|
||||
.measure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: max-content;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.extraBadges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
max-width: 320px;
|
||||
}
|
||||
75
frontend/src/components/OverflowBadges/OverflowBadges.tsx
Normal file
75
frontend/src/components/OverflowBadges/OverflowBadges.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { type CSSProperties, useRef } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
import ItemBadge from './ItemBadge';
|
||||
import type { OverflowBadgeItem } from './types';
|
||||
import { useOverflowBadgesLayout } from './useOverflowBadgesLayout';
|
||||
|
||||
import styles from './OverflowBadges.module.scss';
|
||||
|
||||
interface OverflowBadgesProps {
|
||||
items: OverflowBadgeItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of badges: as many as fit the width inline, the rest revealed on hover
|
||||
* of a "+N" badge. A lone badge too wide to fit truncates instead.
|
||||
*/
|
||||
function OverflowBadges({ items }: OverflowBadgesProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const { visibleCount, firstMaxWidth, isMeasuring } = useOverflowBadgesLayout(
|
||||
items,
|
||||
containerRef,
|
||||
measureRef,
|
||||
);
|
||||
|
||||
const visible = items.slice(0, visibleCount);
|
||||
const extra = items.slice(visibleCount);
|
||||
// The app sets `--badge-max-width` globally, which outranks Badge's `maxWidth` prop.
|
||||
const containerStyle =
|
||||
firstMaxWidth === undefined
|
||||
? undefined
|
||||
: ({ '--badge-max-width': `${firstMaxWidth}px` } as CSSProperties);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.badges} style={containerStyle}>
|
||||
{visible.map(({ key, label }) => (
|
||||
<ItemBadge key={key} testId="overflow-badges-item">
|
||||
{label}
|
||||
</ItemBadge>
|
||||
))}
|
||||
{extra.length > 0 && (
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
title={
|
||||
<div className={styles.extraBadges}>
|
||||
{extra.map(({ key, label }) => (
|
||||
<ItemBadge key={key}>{label}</ItemBadge>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Stop the click so the "+N" badge doesn't trigger the row's click. */}
|
||||
<span
|
||||
role="presentation"
|
||||
className={styles.overflow}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<ItemBadge testId="overflow-badges-more">+{extra.length}</ItemBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isMeasuring && (
|
||||
<div ref={measureRef} className={styles.measure} aria-hidden>
|
||||
{items.map(({ key, label }) => (
|
||||
<ItemBadge key={key}>{label}</ItemBadge>
|
||||
))}
|
||||
<ItemBadge>+{items.length}</ItemBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverflowBadges;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import OverflowBadges from '../OverflowBadges';
|
||||
import { computeOverflowBadgesLayout } from '../utils';
|
||||
|
||||
const items = ['alpha', 'beta', 'gamma', 'delta'].map((label) => ({
|
||||
key: label,
|
||||
label,
|
||||
}));
|
||||
|
||||
describe('computeOverflowBadgesLayout', () => {
|
||||
it('fits as many badges as the width allows, keeping room for +N', () => {
|
||||
// 50 + 4 + 50 + 4 + 30 (+N) = 138 fits; a third badge would not.
|
||||
expect(
|
||||
computeOverflowBadgesLayout(140, 4, [50, 50, 50, 50], 30),
|
||||
).toStrictEqual({ visibleCount: 2 });
|
||||
});
|
||||
|
||||
it('shows every badge without +N when all fit', () => {
|
||||
expect(computeOverflowBadgesLayout(200, 4, [50, 50, 50], 30)).toStrictEqual({
|
||||
visibleCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates the first badge when even it does not fit', () => {
|
||||
expect(computeOverflowBadgesLayout(100, 4, [300, 50], 30)).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
firstMaxWidth: 66,
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a lone too-wide badge the full width', () => {
|
||||
expect(computeOverflowBadgesLayout(100, 4, [300], 30)).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
firstMaxWidth: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns visibleCount 0 when badgeWidths is empty', () => {
|
||||
expect(computeOverflowBadgesLayout(100, 4, [], 30)).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OverflowBadges', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('collapses the badges that do not fit into +N', () => {
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(160);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: 50 } as DOMRect);
|
||||
|
||||
render(<OverflowBadges items={items} />);
|
||||
|
||||
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(2);
|
||||
expect(screen.getByTestId('overflow-badges-more')).toHaveTextContent('+2');
|
||||
});
|
||||
|
||||
it('omits +N when every badge fits', () => {
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: 50 } as DOMRect);
|
||||
|
||||
render(<OverflowBadges items={items} />);
|
||||
|
||||
expect(screen.getAllByTestId('overflow-badges-item')).toHaveLength(4);
|
||||
expect(screen.queryByTestId('overflow-badges-more')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
frontend/src/components/OverflowBadges/types.ts
Normal file
20
frontend/src/components/OverflowBadges/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Key, ReactNode } from 'react';
|
||||
|
||||
export interface OverflowBadgeItem {
|
||||
key: Key;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
export interface OverflowBadgesLayout {
|
||||
visibleCount: number;
|
||||
/** Set when even the first badge does not fit and must truncate. */
|
||||
firstMaxWidth?: number;
|
||||
}
|
||||
|
||||
export interface OverflowBadgesMeasurement {
|
||||
/** The items these widths were measured for. */
|
||||
itemsKey: string;
|
||||
badgeWidths: number[];
|
||||
overflowWidth: number;
|
||||
gap: number;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type RefObject, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
OverflowBadgeItem,
|
||||
OverflowBadgesLayout,
|
||||
OverflowBadgesMeasurement,
|
||||
} from './types';
|
||||
import { computeOverflowBadgesLayout } from './utils';
|
||||
|
||||
/**
|
||||
* Fits the badges to the container's width, re-fitting on resize. Badge widths
|
||||
* are measured once per set of items, from a measuring row holding every badge
|
||||
* at its natural width followed by "+N", rendered only while `isMeasuring`.
|
||||
*/
|
||||
export function useOverflowBadgesLayout(
|
||||
items: OverflowBadgeItem[],
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
measureRef: RefObject<HTMLElement>,
|
||||
): OverflowBadgesLayout & { isMeasuring: boolean } {
|
||||
const itemsKey = items.map((item) => String(item.key)).join('\u0000');
|
||||
const [measurement, setMeasurement] =
|
||||
useState<OverflowBadgesMeasurement | null>(null);
|
||||
const [available, setAvailable] = useState(0);
|
||||
const isMeasuring = items.length > 0 && measurement?.itemsKey !== itemsKey;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!isMeasuring || !container || !measure) {
|
||||
return;
|
||||
}
|
||||
// Rounded up so sub-pixel widths never add up past the container.
|
||||
const badgeWidths = Array.from(measure.children, (el) =>
|
||||
Math.ceil(el.getBoundingClientRect().width),
|
||||
);
|
||||
const overflowWidth = badgeWidths.pop() ?? 0;
|
||||
setMeasurement({
|
||||
itemsKey,
|
||||
badgeWidths,
|
||||
overflowWidth,
|
||||
gap: parseFloat(getComputedStyle(container).columnGap) || 0,
|
||||
});
|
||||
}, [isMeasuring, itemsKey, containerRef, measureRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return undefined;
|
||||
}
|
||||
const update = (): void => setAvailable(container.clientWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(container);
|
||||
return (): void => observer.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
const layout = useMemo(
|
||||
(): OverflowBadgesLayout =>
|
||||
isMeasuring || !measurement || available === 0
|
||||
? { visibleCount: items.length }
|
||||
: computeOverflowBadgesLayout(
|
||||
available,
|
||||
measurement.gap,
|
||||
measurement.badgeWidths,
|
||||
measurement.overflowWidth,
|
||||
),
|
||||
[isMeasuring, measurement, available, items.length],
|
||||
);
|
||||
|
||||
return { ...layout, isMeasuring };
|
||||
}
|
||||
33
frontend/src/components/OverflowBadges/utils.ts
Normal file
33
frontend/src/components/OverflowBadges/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { OverflowBadgesLayout } from './types';
|
||||
|
||||
/**
|
||||
* How many badges fit `available` px, keeping room for the "+N" badge while any
|
||||
* are left out. Always shows at least one, capped to truncate when too wide.
|
||||
*/
|
||||
export const computeOverflowBadgesLayout = (
|
||||
available: number,
|
||||
gap: number,
|
||||
badgeWidths: number[],
|
||||
overflowWidth: number,
|
||||
): OverflowBadgesLayout => {
|
||||
if (badgeWidths.length === 0) {
|
||||
return { visibleCount: 0 };
|
||||
}
|
||||
|
||||
let used = 0;
|
||||
let visibleCount = 0;
|
||||
for (let i = 0; i < badgeWidths.length; i++) {
|
||||
const next = used + (i > 0 ? gap : 0) + badgeWidths[i];
|
||||
const reserve = i < badgeWidths.length - 1 ? gap + overflowWidth : 0;
|
||||
if (next + reserve > available) {
|
||||
break;
|
||||
}
|
||||
used = next;
|
||||
visibleCount++;
|
||||
}
|
||||
if (visibleCount > 0) {
|
||||
return { visibleCount };
|
||||
}
|
||||
const reserve = badgeWidths.length > 1 ? gap + overflowWidth : 0;
|
||||
return { visibleCount: 1, firstMaxWidth: Math.max(available - reserve, 0) };
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
@@ -31,7 +32,7 @@ type TanStackHeaderRowProps<TData = unknown> = {
|
||||
onRemoveColumn?: (columnId: string) => void;
|
||||
orderBy?: SortState | null;
|
||||
onSort?: (sort: SortState | null) => void;
|
||||
/** Last column cannot be resized */
|
||||
/** The fill column and any after it cannot be resized */
|
||||
isLastColumn?: boolean;
|
||||
};
|
||||
|
||||
@@ -89,6 +90,19 @@ function TanStackHeaderRow<TData>({
|
||||
): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// TanStack starts the drag from the configured size, which an auto or
|
||||
// redistributed column does not render at.
|
||||
const renderedWidth = Math.round(
|
||||
event.currentTarget.closest('th')?.getBoundingClientRect().width ?? 0,
|
||||
);
|
||||
if (header && renderedWidth > 0 && renderedWidth !== header.getSize()) {
|
||||
flushSync(() => {
|
||||
header.getContext().table.setColumnSizing((sizing) => ({
|
||||
...sizing,
|
||||
[header.column.id]: renderedWidth,
|
||||
}));
|
||||
});
|
||||
}
|
||||
resizeHandler?.(event);
|
||||
};
|
||||
const {
|
||||
|
||||
@@ -53,7 +53,11 @@ import { useFlatItems } from './useFlatItems';
|
||||
import { useResetScroll } from './useResetScroll';
|
||||
import { useRowKeyData } from './useRowKeyData';
|
||||
import { useTableParams } from './useTableParams';
|
||||
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
|
||||
import {
|
||||
buildPageSizeItems,
|
||||
buildTanstackColumnDef,
|
||||
getFillColumnIndex,
|
||||
} from './utils';
|
||||
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
|
||||
|
||||
import tableStyles from './TanStackTable.module.scss';
|
||||
@@ -428,6 +432,9 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
);
|
||||
|
||||
const tableHeader = useCallback(() => {
|
||||
const fillColumnIndex = getFillColumnIndex(
|
||||
flatHeaders.map((header) => columnsById.get(header.id)),
|
||||
);
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
@@ -453,7 +460,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
canRemoveColumn={canRemoveColumn}
|
||||
orderBy={orderBy}
|
||||
onSort={setOrderBy}
|
||||
isLastColumn={index === flatHeaders.length - 1}
|
||||
isLastColumn={index >= fillColumnIndex}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
|
||||
import type { TableColumnDef } from './types';
|
||||
import { getColumnWidthStyle } from './utils';
|
||||
import { getColumnWidthStyle, getFillColumnIndex } from './utils';
|
||||
|
||||
export function VirtuosoTableColGroup<TData>({
|
||||
columns,
|
||||
@@ -13,6 +13,9 @@ export function VirtuosoTableColGroup<TData>({
|
||||
const visibleTanstackColumns = table.getVisibleFlatColumns();
|
||||
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
|
||||
const columnSizing = table.getState().columnSizing;
|
||||
const fillColumnIndex = getFillColumnIndex(
|
||||
visibleTanstackColumns.map((c) => columnDefsById.get(c.id)),
|
||||
);
|
||||
|
||||
return (
|
||||
<colgroup>
|
||||
@@ -22,7 +25,7 @@ export function VirtuosoTableColGroup<TData>({
|
||||
return <col key={tanstackCol.id} />;
|
||||
}
|
||||
const persistedWidth = columnSizing[tanstackCol.id];
|
||||
const isLastColumn = index === visibleTanstackColumns.length - 1;
|
||||
const isLastColumn = index === fillColumnIndex;
|
||||
return (
|
||||
<col
|
||||
key={tanstackCol.id}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import type { TableColumnDef } from '../types';
|
||||
import { defaultColumns, renderTanStackTable, type TestRow } from './testUtils';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
const actionsColumn: TableColumnDef<TestRow> = {
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: (): string => 'act',
|
||||
width: { fixed: '96px', passLastColumnFill: true },
|
||||
};
|
||||
|
||||
const colWidths = (container: HTMLElement): string[] =>
|
||||
Array.from(container.querySelectorAll('colgroup col')).map(
|
||||
(col) => (col as HTMLElement).style.width,
|
||||
);
|
||||
|
||||
const isResizable = (headerText: string): boolean =>
|
||||
Boolean(
|
||||
screen
|
||||
.getByText(headerText)
|
||||
.closest('th')
|
||||
?.querySelector('[title="Drag to resize column"]'),
|
||||
);
|
||||
|
||||
describe('TanStackTable column fill', () => {
|
||||
it('fills and locks the last column by default', () => {
|
||||
const { container } = renderTanStackTable();
|
||||
|
||||
expect(colWidths(container).at(-1)).toBe('100%');
|
||||
expect(isResizable('Name')).toBe(true);
|
||||
expect(isResizable('Value')).toBe(false);
|
||||
});
|
||||
|
||||
it('hands the fill to the previous column when the last passes it on', () => {
|
||||
const { container } = renderTanStackTable({
|
||||
props: { columns: [...defaultColumns, actionsColumn] },
|
||||
});
|
||||
|
||||
const widths = colWidths(container);
|
||||
expect(widths.at(-2)).toBe('100%');
|
||||
expect(widths.at(-1)).toBe('96px');
|
||||
expect(isResizable('Name')).toBe(true);
|
||||
expect(isResizable('Value')).toBe(false);
|
||||
expect(isResizable('Actions')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TableColumnDef } from '../types';
|
||||
import { getFillColumnIndex } from '../utils';
|
||||
|
||||
const column = (
|
||||
id: string,
|
||||
width?: TableColumnDef<unknown>['width'],
|
||||
): TableColumnDef<unknown> => ({
|
||||
id,
|
||||
header: id,
|
||||
cell: (): null => null,
|
||||
width,
|
||||
});
|
||||
|
||||
describe('getFillColumnIndex', () => {
|
||||
it('fills the last column by default', () => {
|
||||
expect(getFillColumnIndex([column('a'), column('b')])).toBe(1);
|
||||
});
|
||||
|
||||
it('fills the previous column when the last passes it on', () => {
|
||||
expect(
|
||||
getFillColumnIndex([
|
||||
column('a'),
|
||||
column('b'),
|
||||
column('actions', { fixed: '96px', passLastColumnFill: true }),
|
||||
]),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,8 @@ export type TableColumnDef<
|
||||
default?: number | string;
|
||||
max?: number | string;
|
||||
ignoreLastColumnFill?: boolean;
|
||||
/** When last, hand the fill to the previous column instead of taking it. */
|
||||
passLastColumnFill?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,19 @@ export const getColumnWidthStyle = <TData>(
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Index of the column that fills spare width: the last one, or the one before
|
||||
* it when the last sets `width.passLastColumnFill`.
|
||||
*/
|
||||
export const getFillColumnIndex = <TData>(
|
||||
columns: (TableColumnDef<TData> | undefined)[],
|
||||
): number => {
|
||||
const lastIndex = columns.length - 1;
|
||||
return columns[lastIndex]?.width?.passLastColumnFill
|
||||
? lastIndex - 1
|
||||
: lastIndex;
|
||||
};
|
||||
|
||||
const isSkeletonRow = (row: unknown): boolean => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -121,13 +121,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeText {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
@@ -53,6 +54,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
data: alertsData,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching: isFetchingAlerts,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
@@ -60,6 +62,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const location = useAppLocation();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [initialValues, setInitialValues] =
|
||||
@@ -83,20 +86,31 @@ export function PlannedDowntime(): JSX.Element {
|
||||
}
|
||||
}, [form, isOpen]);
|
||||
|
||||
const [searchValue, setSearchValue] = React.useState<string>(
|
||||
urlQuery.get('search') || '',
|
||||
);
|
||||
const [searchValue, setSearchValue] = React.useState<string>(() => {
|
||||
const searchFromUrlQuery = urlQuery.get('search');
|
||||
if (searchFromUrlQuery) {
|
||||
return searchFromUrlQuery;
|
||||
}
|
||||
const search =
|
||||
(typeof window !== 'undefined' && window.location.search) ||
|
||||
location.search ||
|
||||
'';
|
||||
return new URLSearchParams(search).get('search') || '';
|
||||
});
|
||||
const [deleteData, setDeleteData] = useState<{ id: string; name: string }>();
|
||||
const [isEditMode, setEditMode] = useState<boolean>(false);
|
||||
|
||||
const updateUrlWithSearch = useDebouncedFn((value) => {
|
||||
const searchValue = value as string;
|
||||
if (searchValue) {
|
||||
urlQuery.set('search', searchValue);
|
||||
const nextSearch = value as string;
|
||||
const search =
|
||||
(typeof window !== 'undefined' && window.location.search) || location.search;
|
||||
const params = new URLSearchParams(search);
|
||||
if (nextSearch) {
|
||||
params.set('search', nextSearch);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
params.delete('search');
|
||||
}
|
||||
const url = `/alerts?${urlQuery.toString()}`;
|
||||
const url = `/alerts?${params.toString()}`;
|
||||
navigate(url, { replace: true });
|
||||
}, 300);
|
||||
|
||||
@@ -127,9 +141,11 @@ export function PlannedDowntime(): JSX.Element {
|
||||
|
||||
const canEdit = user?.role !== USER_ROLES.VIEWER;
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const isAlertsLoading = isLoading || (!alertsData && isFetchingAlerts);
|
||||
const columns = React.useMemo(
|
||||
() => getPlannedDowntimeColumns(formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
() =>
|
||||
getPlannedDowntimeColumns(formatTimezoneAdjustedTimestamp, isAlertsLoading),
|
||||
[formatTimezoneAdjustedTimestamp, isAlertsLoading],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback((): void => {
|
||||
@@ -204,7 +220,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>
|
||||
Planned Downtime
|
||||
All planned downtimes
|
||||
</Typography.Title>
|
||||
<span className={styles.countPill}>{totalDowntimes}</span>
|
||||
</div>
|
||||
@@ -226,7 +242,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
disabledTooltip="You need edit permissions to create a planned downtime"
|
||||
testId="planned-downtime-create"
|
||||
>
|
||||
New downtime
|
||||
New Downtime
|
||||
</Button>
|
||||
<TextToolTip
|
||||
text="More details on how to schedule planned downtime"
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
AlertmanagertypesRepeatTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { mockLocation } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
|
||||
import { render, userEvent } from 'tests/test-utils';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
@@ -102,8 +101,6 @@ const mockDowntimeQueryResult: Partial<DowntimeQueryResult> = {
|
||||
|
||||
let mockUrlQuery: URLSearchParams;
|
||||
|
||||
mockAppLocation({ pathname: '/alerts' });
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
@@ -127,29 +124,44 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockRulesState: {
|
||||
data?: { data: Array<{ id: number | string; alert: string }> };
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
} = {
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): {
|
||||
data: { data: [] };
|
||||
isError: false;
|
||||
isLoading: false;
|
||||
} => ({
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
useListRules: (): typeof mockRulesState => mockRulesState,
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlQuery = new URLSearchParams();
|
||||
mockLocation('/alerts');
|
||||
mockAppLocation({
|
||||
pathname: '/alerts',
|
||||
search: '',
|
||||
});
|
||||
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
});
|
||||
|
||||
it('renders the header, search and create action', () => {
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
|
||||
expect(screen.getByText('All planned downtimes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
@@ -193,14 +205,14 @@ describe('PlannedDowntime Component', () => {
|
||||
expect(screen.getByText(MOCK_DOWNTIME_3_NAME)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the key schedule details and hides secondary columns by default', async () => {
|
||||
it('shows the key schedule details, timezone and duration, and hides secondary columns by default', async () => {
|
||||
renderPage();
|
||||
|
||||
await screen.findByText(MOCK_DOWNTIME_1_NAME);
|
||||
expect(screen.getByText(/\(Asia\/Kolkata\)$/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Weekly on Mon · 2h')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Asia/Kolkata')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('2h')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Asia/Kolkata').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('2h').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Database maintenance')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('creator@signoz.io')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('editor@signoz.io')).not.toBeInTheDocument();
|
||||
@@ -219,7 +231,9 @@ describe('PlannedDowntime Component', () => {
|
||||
'Downtime',
|
||||
'Status',
|
||||
'Schedule',
|
||||
'Timezone',
|
||||
'Repeats',
|
||||
'Duration',
|
||||
'Alerts Silenced',
|
||||
]);
|
||||
});
|
||||
@@ -290,4 +304,116 @@ describe('PlannedDowntime Component', () => {
|
||||
await screen.findByText(MOCK_DOWNTIME_1_NAME);
|
||||
expect(screen.queryByTestId('downtime-actions-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves existing query parameters such as sort and pagination when typing in search (Bug 54)', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockAppLocation({
|
||||
pathname: '/alerts',
|
||||
search:
|
||||
'?tab=Configuration&subTab=planned-downtime&orderBy=name&order=asc&page=2&limit=20',
|
||||
});
|
||||
const navigateSpy = mockNavigate();
|
||||
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(screen.getByTestId(SEARCH_TEST_ID), {
|
||||
target: { value: 'query' },
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(350);
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalled();
|
||||
const callArg = navigateSpy.mock.calls[0][0];
|
||||
expect(callArg).toContain('tab=Configuration');
|
||||
expect(callArg).toContain('subTab=planned-downtime');
|
||||
expect(callArg).toContain('orderBy=name');
|
||||
expect(callArg).toContain('order=asc');
|
||||
expect(callArg).toContain('page=2');
|
||||
expect(callArg).toContain('limit=20');
|
||||
expect(callArg).toContain('search=query');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows skeleton while alert rules are loading for scoped downtime (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
isFetching: true,
|
||||
};
|
||||
const scopedDowntime = createMockDowntime({
|
||||
id: 'scoped-1',
|
||||
name: 'Scoped Downtime Loading',
|
||||
schedule: buildSchedule({ startTime: MOCK_DATE_1 }),
|
||||
alertIds: ['rule-1'],
|
||||
});
|
||||
mockDowntimeQueryResult.data = {
|
||||
data: [scopedDowntime],
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Scoped Downtime Loading');
|
||||
expect(screen.getByTestId('alerts-silenced-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All alert rules')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
const scopedDowntime = createMockDowntime({
|
||||
id: 'scoped-2',
|
||||
name: 'Scoped Downtime Deleted Rule',
|
||||
schedule: buildSchedule({ startTime: MOCK_DATE_1 }),
|
||||
alertIds: ['deleted-rule-id'],
|
||||
});
|
||||
mockDowntimeQueryResult.data = {
|
||||
data: [scopedDowntime],
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Scoped Downtime Deleted Rule');
|
||||
expect(screen.getByText('Unknown rule')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All alert rules')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows resolved alert rule name when scoped rule ID exists', async () => {
|
||||
mockRulesState = {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rule-42',
|
||||
alert: 'Disk Space High',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
const scopedDowntime = createMockDowntime({
|
||||
id: 'scoped-3',
|
||||
name: 'Scoped Downtime Resolved',
|
||||
schedule: buildSchedule({ startTime: MOCK_DATE_1 }),
|
||||
alertIds: ['rule-42'],
|
||||
});
|
||||
mockDowntimeQueryResult.data = {
|
||||
data: [scopedDowntime],
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Scoped Downtime Resolved');
|
||||
expect(screen.getByText('Disk Space High')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All alert rules')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { AlertmanagertypesMaintenanceStatusDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import OverflowBadges from 'components/OverflowBadges/OverflowBadges';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relativeTime';
|
||||
@@ -31,8 +33,11 @@ const STATUS_BADGE: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const COLUMN_WIDTH = { default: 'auto', min: '120px' };
|
||||
|
||||
export function getPlannedDowntimeColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
isAlertsLoading?: boolean,
|
||||
): TableColumnDef<DowntimeSchedulesTableData>[] {
|
||||
const textCell = (value: string | undefined): JSX.Element => (
|
||||
<TanStackTable.Text title={value || undefined}>
|
||||
@@ -66,7 +71,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'name',
|
||||
header: 'Downtime',
|
||||
accessorKey: 'name',
|
||||
width: { default: '100%', min: '200px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableRemove: false,
|
||||
@@ -84,7 +89,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
accessorKey: 'status',
|
||||
width: { default: '110px', min: '100px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -103,7 +108,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'window',
|
||||
header: 'Schedule',
|
||||
accessorFn: (row) => row.schedule?.startTime,
|
||||
width: { default: '340px', min: '220px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -113,8 +118,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'timezone',
|
||||
header: 'Timezone',
|
||||
accessorFn: (row) => row.schedule?.timezone,
|
||||
width: { default: '130px', min: '110px' },
|
||||
defaultVisibility: false,
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -124,7 +128,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'repeats',
|
||||
header: 'Repeats',
|
||||
accessorFn: (row) => row.schedule?.recurrence?.repeatType,
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -134,8 +138,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
accessorFn: (row) => row.schedule?.recurrence?.duration,
|
||||
width: { default: '110px', min: '90px' },
|
||||
defaultVisibility: false,
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -146,42 +149,46 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'alertsSilenced',
|
||||
header: 'Alerts Silenced',
|
||||
accessorKey: 'alertIds',
|
||||
width: { default: '200px', min: '140px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element =>
|
||||
row.alertOptions.length ? (
|
||||
<div
|
||||
className={styles.badges}
|
||||
title={row.alertOptions
|
||||
.flatMap((alert) =>
|
||||
typeof alert.label === 'string' ? [alert.label] : [],
|
||||
)
|
||||
.join(', ')}
|
||||
>
|
||||
{row.alertOptions.map((alert) => (
|
||||
<Badge
|
||||
key={alert.value}
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
{alert.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
All alert rules
|
||||
</Badge>
|
||||
),
|
||||
cell: ({ row }): JSX.Element => {
|
||||
const hasScopedAlerts = Boolean(row.alertIds && row.alertIds.length > 0);
|
||||
if (!hasScopedAlerts) {
|
||||
return (
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
All alert rules
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAlertsLoading) {
|
||||
return (
|
||||
<Skeleton.Input
|
||||
testId="alerts-silenced-skeleton"
|
||||
active
|
||||
size="small"
|
||||
style={{ width: 100, height: 22 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OverflowBadges
|
||||
items={row.alertOptions.map((alert) => ({
|
||||
key: String(alert.value),
|
||||
label: alert.label,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: 'Description',
|
||||
accessorKey: 'description',
|
||||
width: { default: '200px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -192,7 +199,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -203,7 +210,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
@@ -214,7 +221,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -225,7 +232,7 @@ export function getPlannedDowntimeColumns(
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { SortState } from 'components/TanStackTableView/types';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
|
||||
import { getAlertOptionsFromIds } from './PlannedDowntimeutils';
|
||||
import { DowntimeAlertOption, DowntimeSchedulesTableData } from './types';
|
||||
|
||||
export const PLANNED_DOWNTIME_QUERY_PARAMS = {
|
||||
@@ -72,16 +71,38 @@ function usePlannedDowntimeTable(
|
||||
|
||||
const filteredDowntimes = useMemo(() => {
|
||||
const search = searchValue.toLowerCase();
|
||||
const alertOptionsMap = new Map<string, DowntimeAlertOption>();
|
||||
alertOptions.forEach((option) => {
|
||||
if (option?.value != null) {
|
||||
alertOptionsMap.set(String(option.value), option);
|
||||
}
|
||||
});
|
||||
|
||||
return downtimes
|
||||
.filter(
|
||||
(downtime) =>
|
||||
downtime.name.toLowerCase().includes(search) ||
|
||||
downtime.id === searchValue,
|
||||
)
|
||||
.map((downtime) => ({
|
||||
...downtime,
|
||||
alertOptions: getAlertOptionsFromIds(downtime.alertIds || [], alertOptions),
|
||||
}));
|
||||
.map((downtime) => {
|
||||
const resolvedAlertOptions: DowntimeAlertOption[] = (
|
||||
downtime.alertIds || []
|
||||
).map((id) => {
|
||||
const found = alertOptionsMap.get(String(id));
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
return {
|
||||
label: 'Unknown rule',
|
||||
value: id,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...downtime,
|
||||
alertOptions: resolvedAlertOptions,
|
||||
};
|
||||
});
|
||||
}, [downtimes, alertOptions, searchValue]);
|
||||
|
||||
const pagedDowntimes = useMemo(() => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -121,13 +121,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeText {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -102,7 +102,7 @@ function RoutingPolicies(): JSX.Element {
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>
|
||||
Routing Policies
|
||||
All routing policies
|
||||
</Typography.Title>
|
||||
<span className={styles.countPill}>{routingPoliciesData.length}</span>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ function RoutingPolicies(): JSX.Element {
|
||||
disabledTooltip="You need edit permissions to create a routing policy"
|
||||
testId="routing-policies-create"
|
||||
>
|
||||
New routing policy
|
||||
New Routing Policy
|
||||
</Button>
|
||||
<TextToolTip
|
||||
text="More details on how to configure routing policies"
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('RoutingPolicies', () => {
|
||||
|
||||
it('should render components properly', () => {
|
||||
render(<RoutingPolicies />);
|
||||
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
|
||||
expect(screen.getByText('All routing policies')).toBeInTheDocument();
|
||||
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(CREATE_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('routing-policy-list')).toBeInTheDocument();
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('RoutingPolicyList', () => {
|
||||
const resizable = Array.from(document.querySelectorAll('th'))
|
||||
.filter((th) => th.querySelector('[title="Drag to resize column"]'))
|
||||
.map((th) => th.textContent?.trim());
|
||||
expect(resizable).toStrictEqual(['Routing Policy', 'Expression', 'Channels']);
|
||||
expect(resizable).toStrictEqual(['Routing Policy', 'Channels', 'Expression']);
|
||||
});
|
||||
|
||||
it('opens the policy on row click', async () => {
|
||||
|
||||
@@ -202,6 +202,39 @@ describe('useRoutingPolicies', () => {
|
||||
expect(callArg).toContain('search=test+search');
|
||||
});
|
||||
|
||||
it('should preserve existing query parameters such as sort and pagination when updating search (Bug 54)', async () => {
|
||||
const { result } = renderHookWithWrapper(
|
||||
'/alerts?tab=Configuration&subTab=routing-policies',
|
||||
);
|
||||
// nuqs writes sort and pagination to the URL before the router location catches up
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/alerts?tab=Configuration&subTab=routing-policies&orderBy=name&order=asc&page=2&limit=20',
|
||||
);
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
result.current.setSearchTerm('policy');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
window.history.replaceState(null, '', '/');
|
||||
}
|
||||
|
||||
const callArg = mockNavigate.mock.calls[0][0];
|
||||
expect(callArg).toContain('tab=Configuration');
|
||||
expect(callArg).toContain('subTab=routing-policies');
|
||||
expect(callArg).toContain('orderBy=name');
|
||||
expect(callArg).toContain('order=asc');
|
||||
expect(callArg).toContain('page=2');
|
||||
expect(callArg).toContain('limit=20');
|
||||
expect(callArg).toContain('search=policy');
|
||||
});
|
||||
|
||||
it('should remove search param from URL when search is cleared', async () => {
|
||||
const { result } = renderHookWithWrapper('/alerts?search=existing');
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import OverflowBadges from 'components/OverflowBadges/OverflowBadges';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relativeTime';
|
||||
@@ -7,6 +7,8 @@ import { toRelativeTime } from 'pages/AlertList/NotificationChannels/utils/relat
|
||||
import styles from './RoutingPolicies.module.scss';
|
||||
import { RoutingPolicy } from './types';
|
||||
|
||||
const COLUMN_WIDTH = { default: 'auto', min: '120px' };
|
||||
|
||||
export function getRoutingPolicyColumns(
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): TableColumnDef<RoutingPolicy>[] {
|
||||
@@ -42,7 +44,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'name',
|
||||
header: 'Routing Policy',
|
||||
accessorKey: 'name',
|
||||
width: { default: '100%', min: '220px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
enableRemove: false,
|
||||
@@ -56,11 +58,31 @@ export function getRoutingPolicyColumns(
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
header: 'Channels',
|
||||
accessorKey: 'channels',
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element =>
|
||||
row.channels?.length ? (
|
||||
<OverflowBadges
|
||||
items={row.channels.map((channel) => ({
|
||||
key: channel,
|
||||
label: channel,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<TanStackTable.Text>-</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expression',
|
||||
header: 'Expression',
|
||||
accessorKey: 'expression',
|
||||
width: { default: '260px', min: '160px' },
|
||||
width: COLUMN_WIDTH,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
@@ -70,37 +92,11 @@ export function getRoutingPolicyColumns(
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
header: 'Channels',
|
||||
accessorKey: 'channels',
|
||||
width: { default: '220px', min: '140px' },
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
enableMove: false,
|
||||
cell: ({ row }): JSX.Element =>
|
||||
row.channels?.length ? (
|
||||
<div className={styles.badges} title={row.channels.join(', ')}>
|
||||
{row.channels.map((channel) => (
|
||||
<Badge
|
||||
key={channel}
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
{channel}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TanStackTable.Text>-</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
header: 'Created By',
|
||||
accessorKey: 'createdBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -111,7 +107,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'createdAt',
|
||||
header: 'Created At',
|
||||
accessorKey: 'createdAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
@@ -122,7 +118,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'updatedBy',
|
||||
header: 'Updated By',
|
||||
accessorKey: 'updatedBy',
|
||||
width: { default: '180px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: false,
|
||||
enableResize: true,
|
||||
@@ -133,7 +129,7 @@ export function getRoutingPolicyColumns(
|
||||
id: 'updatedAt',
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updatedAt',
|
||||
width: { default: '150px', min: '120px' },
|
||||
width: COLUMN_WIDTH,
|
||||
defaultVisibility: false,
|
||||
enableSort: true,
|
||||
enableResize: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
|
||||
@@ -30,9 +31,20 @@ import './styles.scss';
|
||||
function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useAppLocation();
|
||||
|
||||
// Local state
|
||||
const [searchTerm, setSearchTerm] = useState(urlQuery.get('search') || '');
|
||||
const [searchTerm, setSearchTerm] = useState(() => {
|
||||
const searchFromUrlQuery = urlQuery.get('search');
|
||||
if (searchFromUrlQuery) {
|
||||
return searchFromUrlQuery;
|
||||
}
|
||||
const search =
|
||||
(typeof window !== 'undefined' && window.location.search) ||
|
||||
location.search ||
|
||||
'';
|
||||
return new URLSearchParams(search).get('search') || '';
|
||||
});
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [policyDetailsModalState, setPolicyDetailsModalState] =
|
||||
useState<PolicyDetailsModalState>({
|
||||
@@ -44,12 +56,15 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
|
||||
|
||||
const updateUrlWithSearch = useDebouncedFn((value) => {
|
||||
const searchValue = value as string;
|
||||
const search =
|
||||
(typeof window !== 'undefined' && window.location.search) || location.search;
|
||||
const params = new URLSearchParams(search);
|
||||
if (searchValue) {
|
||||
urlQuery.set('search', searchValue);
|
||||
params.set('search', searchValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
params.delete('search');
|
||||
}
|
||||
const url = `/alerts?${urlQuery.toString()}`;
|
||||
const url = `/alerts?${params.toString()}`;
|
||||
navigate(url, { replace: true });
|
||||
}, 300);
|
||||
|
||||
|
||||
43
frontend/src/hooks/__tests__/useDebouncedFunction.test.ts
Normal file
43
frontend/src/hooks/__tests__/useDebouncedFunction.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import useDebouncedFn from '../useDebouncedFunction';
|
||||
|
||||
describe('useDebouncedFn', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should debounce function execution', () => {
|
||||
const callback = jest.fn();
|
||||
const { result } = renderHook(() => useDebouncedFn(callback, 300));
|
||||
|
||||
result.current('first');
|
||||
result.current('second');
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(299);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(callback).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
it('should cancel pending execution when unmounted (Bug 55)', () => {
|
||||
const callback = jest.fn();
|
||||
const { result, unmount } = renderHook(() => useDebouncedFn(callback, 300));
|
||||
|
||||
result.current('pending');
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
export interface DebouncedFunc<T extends (...args: unknown[]) => unknown> {
|
||||
@@ -28,10 +28,19 @@ const useDebouncedFn = <T extends (...args: Array<unknown>) => unknown>(
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
|
||||
return useMemo(
|
||||
const debounced = useMemo(
|
||||
() => debounce(((...args) => fnRef.current(...args)) as T, wait, options),
|
||||
[options, wait],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
debounced.cancel();
|
||||
},
|
||||
[debounced],
|
||||
);
|
||||
|
||||
return debounced;
|
||||
};
|
||||
|
||||
export default useDebouncedFn;
|
||||
|
||||
@@ -111,7 +111,7 @@ export const NewDowntimeRecurring: Story = {
|
||||
await screen.findByRole('combobox', { name: 'Repeats every' }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Weekly'));
|
||||
await screen.findByText('Duration');
|
||||
await within(await screen.findByRole('dialog')).findByText('Duration');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -47,7 +47,15 @@ const SEEDS: DowntimeSeed[] = [
|
||||
timezone: 'UTC',
|
||||
startsInMs: -2 * HOUR,
|
||||
lastsMs: 6 * HOUR,
|
||||
alertIds: ['rule-5', 'rule-3'],
|
||||
alertIds: [
|
||||
'rule-5',
|
||||
'rule-3',
|
||||
'rule-1',
|
||||
'rule-2',
|
||||
'rule-4',
|
||||
'rule-6',
|
||||
'rule-7',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Nightly ETL window',
|
||||
|
||||
@@ -26,7 +26,7 @@ const SEEDS: PolicySeed[] = [
|
||||
name: 'Critical production to on-call',
|
||||
description: 'Anything critical in prod pages whoever is on call.',
|
||||
expression: 'severity = "critical" AND env = "prod"',
|
||||
channels: [1, 0],
|
||||
channels: [1, 0, 3, 4, 2, 5],
|
||||
},
|
||||
{
|
||||
name: 'Payments team ownership',
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) var(--spacing-6) 0;
|
||||
}
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) var(--spacing-6) 0;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
@@ -19,9 +22,15 @@ const TAB_ITEMS = [
|
||||
];
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const { id: traceId = '' } = useAppParams<'id'>();
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
logTraceEvent(TraceDetailEvents.DetailsTabChanged, {
|
||||
[TraceDetailEventKeys.From]: tab,
|
||||
[TraceDetailEventKeys.To]: value,
|
||||
});
|
||||
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
|
||||
void setTab(value as TraceDetailsTab, { history: 'push' });
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
import { useTraceSummary } from '../useTraceSummary';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
jest.mock('../useTraceSummary', () => ({
|
||||
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
|
||||
}));
|
||||
@@ -416,4 +421,23 @@ describe('TraceDetailsHeader – tabs', () => {
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the tab change with the previous and next tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
jest.mocked(logEvent).mockClear();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-overview'));
|
||||
expect(logEvent).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(logEvent).toHaveBeenCalledWith(TraceDetailEvents.DetailsTabChanged, {
|
||||
view: 'v3',
|
||||
traceId: 'trace-123',
|
||||
from: TraceDetailsTab.Overview,
|
||||
to: TraceDetailsTab.Thread,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,9 @@ import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchPa
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useSpanDetailsOpenedEvent } from '../hooks/useSpanDetailsOpenedEvent';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
@@ -106,6 +108,12 @@ function TraceDetailsOverview({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
useSpanDetailsOpenedEvent({
|
||||
isOpen: panelState.isOpen,
|
||||
spanId: selectedSpan?.span_id,
|
||||
detailsTab: TraceDetailsTab.Overview,
|
||||
logTraceEvent,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
@@ -199,6 +207,7 @@ function TraceDetailsOverview({
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Overview,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { back, hasInAppHistory } from 'lib/router/navigation';
|
||||
import { useAppParams } from 'lib/router/useAppParams';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useSpanDetailsOpenedEvent } from '../hooks/useSpanDetailsOpenedEvent';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useThreadDataLoadedEvent } from './hooks/useThreadDataLoadedEvent';
|
||||
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
|
||||
import { useThreadView } from './hooks/useThreadView';
|
||||
import { useTraceThread } from './hooks/useTraceThread';
|
||||
@@ -23,7 +27,14 @@ import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const INITIAL_LOADER_COUNT = 4;
|
||||
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
interface TraceDetailsThreadProps {
|
||||
// Owned by the page so the load event fires once per trace, not per tab visit.
|
||||
dataLoadedFiredForRef: MutableRefObject<string>;
|
||||
}
|
||||
|
||||
function TraceDetailsThread({
|
||||
dataLoadedFiredForRef,
|
||||
}: TraceDetailsThreadProps): JSX.Element {
|
||||
const { id: traceId } = useAppParams<'id'>();
|
||||
const [, setTab] = useTraceDetailsTab();
|
||||
const [view, setView] = useThreadView();
|
||||
@@ -62,6 +73,21 @@ function TraceDetailsThread(): JSX.Element {
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
|
||||
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
useThreadDataLoadedEvent({
|
||||
traceId: traceId || '',
|
||||
hasSpans: spans.length > 0,
|
||||
spanPanelVariant: spanDetailVariant,
|
||||
firedForRef: dataLoadedFiredForRef,
|
||||
logTraceEvent,
|
||||
});
|
||||
useSpanDetailsOpenedEvent({
|
||||
isOpen: panelState.isOpen,
|
||||
spanId: selectedSpan?.span_id,
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
logTraceEvent,
|
||||
});
|
||||
|
||||
const spanDetails = panelState.isOpen && selectedSpan && (
|
||||
<ThreadSpanDetails
|
||||
panelState={panelState}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { MutableRefObject, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import {
|
||||
GetTraceSummary200,
|
||||
SpantypesGettableTraceSummaryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../../constants';
|
||||
import { TraceDetailEvents } from '../../../events';
|
||||
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
|
||||
import { useThreadDataLoadedEvent } from '../useThreadDataLoadedEvent';
|
||||
|
||||
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
|
||||
'trace-a': {
|
||||
startTimestampMillis: 1_000,
|
||||
endTimestampMillis: 4_000,
|
||||
totalSpansCount: 12,
|
||||
totalErrorSpansCount: 2,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_ERROR',
|
||||
},
|
||||
'trace-b': {
|
||||
startTimestampMillis: 5_000,
|
||||
endTimestampMillis: 5_500,
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
hasMissingSpans: false,
|
||||
rootServiceEntryPoint: 'POST /chat',
|
||||
rootServiceName: 'agent',
|
||||
rootSpanStatusCode: 'STATUS_CODE_OK',
|
||||
},
|
||||
};
|
||||
|
||||
interface HookProps {
|
||||
traceId: string;
|
||||
hasSpans: boolean;
|
||||
spanPanelVariant?: SpanDetailVariant;
|
||||
}
|
||||
|
||||
function renderDataLoadedEvent(
|
||||
initialProps: HookProps,
|
||||
firedForRef: MutableRefObject<string> = { current: '' },
|
||||
): {
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
|
||||
rerender: (props: HookProps) => void;
|
||||
queryClient: QueryClient;
|
||||
} {
|
||||
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = renderHook(
|
||||
({ traceId, hasSpans, spanPanelVariant }: HookProps) =>
|
||||
useThreadDataLoadedEvent({
|
||||
traceId,
|
||||
hasSpans,
|
||||
spanPanelVariant: spanPanelVariant ?? SpanDetailVariant.DOCKED_RIGHT,
|
||||
firedForRef,
|
||||
logTraceEvent,
|
||||
}),
|
||||
{ initialProps, wrapper },
|
||||
);
|
||||
return { logTraceEvent, rerender, queryClient };
|
||||
}
|
||||
|
||||
describe('useThreadDataLoadedEvent', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
|
||||
const body: GetTraceSummary200 = {
|
||||
status: 'success',
|
||||
data: SUMMARIES[req.params.traceId as string],
|
||||
};
|
||||
return res(ctx.status(200), ctx.json(body));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the trace summary, then reports it as a thread load', async () => {
|
||||
const { logTraceEvent } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
});
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
totalSpansCount: 12,
|
||||
traceDurationMs: 3_000,
|
||||
hadErrors: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for the thread spans even when the summary is already loaded', async () => {
|
||||
const { logTraceEvent, rerender, queryClient } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: false,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryData(
|
||||
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
rerender({ traceId: 'trace-a', hasSpans: false });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ traceId: 'trace-a', hasSpans: true });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a trace once, including when the tab is visited again', async () => {
|
||||
const firedForRef = { current: '' };
|
||||
const firstVisit = renderDataLoadedEvent(
|
||||
{ traceId: 'trace-a', hasSpans: true },
|
||||
firedForRef,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
firstVisit.rerender({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
spanPanelVariant: SpanDetailVariant.DOCKED,
|
||||
});
|
||||
|
||||
const secondVisit = renderDataLoadedEvent(
|
||||
{ traceId: 'trace-a', hasSpans: true },
|
||||
firedForRef,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
secondVisit.queryClient.getQueryData(
|
||||
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
|
||||
),
|
||||
).toBeDefined(),
|
||||
);
|
||||
secondVisit.rerender({ traceId: 'trace-a', hasSpans: true });
|
||||
|
||||
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports the next trace with its own summary', async () => {
|
||||
const { logTraceEvent, rerender } = renderDataLoadedEvent({
|
||||
traceId: 'trace-a',
|
||||
hasSpans: true,
|
||||
});
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ traceId: 'trace-b', hasSpans: true });
|
||||
|
||||
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
|
||||
expect(logTraceEvent).toHaveBeenLastCalledWith(
|
||||
TraceDetailEvents.DataLoaded,
|
||||
expect.objectContaining({ totalSpansCount: 3, traceDurationMs: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../../events';
|
||||
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
|
||||
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
|
||||
|
||||
interface ThreadDataLoadedEventArgs {
|
||||
traceId: string;
|
||||
hasSpans: boolean;
|
||||
spanPanelVariant: SpanDetailVariant;
|
||||
firedForRef: MutableRefObject<string>;
|
||||
logTraceEvent: TraceDetailLogEvent;
|
||||
}
|
||||
|
||||
export function useThreadDataLoadedEvent({
|
||||
traceId,
|
||||
hasSpans,
|
||||
spanPanelVariant,
|
||||
firedForRef,
|
||||
logTraceEvent,
|
||||
}: ThreadDataLoadedEventArgs): void {
|
||||
// No keepPreviousData: a previous trace's summary must not be reported for this one.
|
||||
const { data } = useGetTraceSummary(
|
||||
{ traceID: traceId },
|
||||
{ query: { enabled: !!traceId, refetchOnMount: false } },
|
||||
);
|
||||
const summary = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!traceId || firedForRef.current === traceId || !hasSpans || !summary) {
|
||||
return;
|
||||
}
|
||||
firedForRef.current = traceId;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
|
||||
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
summary.endTimestampMillis - summary.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
|
||||
});
|
||||
}, [traceId, hasSpans, summary, spanPanelVariant, firedForRef, logTraceEvent]);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export enum TraceDetailEvents {
|
||||
SpanPercentileTimeRangeChanged = 'Trace Detail: Span percentile time range changed',
|
||||
SpanPercentileAttributesSelectorToggled = 'Trace Detail: Span percentile attributes selector toggled',
|
||||
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
|
||||
SpanDetailsOpened = 'Trace Detail: Span details opened',
|
||||
DetailsTabChanged = 'Trace Detail: Details tab changed',
|
||||
}
|
||||
|
||||
export enum TraceDetailEventKeys {
|
||||
@@ -28,7 +30,7 @@ export enum TraceDetailEventKeys {
|
||||
SpanPanelVariant = 'spanPanelVariant',
|
||||
ColorByField = 'colorByField',
|
||||
PreviewFieldsCount = 'previewFieldsCount',
|
||||
// View switched
|
||||
// View switched / details tab changed
|
||||
From = 'from',
|
||||
To = 'to',
|
||||
DwellMs = 'dwellMs',
|
||||
@@ -44,6 +46,8 @@ export enum TraceDetailEventKeys {
|
||||
PercentileValue = 'percentileValue',
|
||||
ResourceAttributeKey = 'resourceAttributeKey',
|
||||
Selected = 'selected',
|
||||
// Data loaded / span details opened: which tab the user is on
|
||||
DetailsTab = 'detailsTab',
|
||||
}
|
||||
|
||||
export type TraceDetailView = 'v2' | 'v3';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
|
||||
import { TraceDetailsTab } from '../../constants';
|
||||
import { TraceDetailEvents } from '../../events';
|
||||
import { useSpanDetailsOpenedEvent } from '../useSpanDetailsOpenedEvent';
|
||||
import { TraceDetailLogEvent } from '../useTraceDetailLogEvent';
|
||||
|
||||
interface PanelProps {
|
||||
isOpen: boolean;
|
||||
spanId?: string;
|
||||
}
|
||||
|
||||
function renderOpenedEvent(initialProps: PanelProps): {
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
|
||||
rerender: (props: PanelProps) => void;
|
||||
} {
|
||||
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
|
||||
const { rerender } = renderHook(
|
||||
({ isOpen, spanId }: PanelProps) =>
|
||||
useSpanDetailsOpenedEvent({
|
||||
isOpen,
|
||||
spanId,
|
||||
detailsTab: TraceDetailsTab.Thread,
|
||||
logTraceEvent,
|
||||
}),
|
||||
{ initialProps },
|
||||
);
|
||||
return { logTraceEvent, rerender };
|
||||
}
|
||||
|
||||
function openedSpanIds(
|
||||
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>,
|
||||
): unknown[] {
|
||||
return logTraceEvent.mock.calls.map(([, attributes]) => attributes?.spanId);
|
||||
}
|
||||
|
||||
describe('useSpanDetailsOpenedEvent', () => {
|
||||
it('waits for the panel to be open on a span, then reports it with the tab', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({ isOpen: false });
|
||||
rerender({ isOpen: true });
|
||||
rerender({ isOpen: false, spanId: 'span-1' });
|
||||
|
||||
expect(logTraceEvent).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
expect(logTraceEvent).toHaveBeenCalledWith(
|
||||
TraceDetailEvents.SpanDetailsOpened,
|
||||
{ spanId: 'span-1', detailsTab: TraceDetailsTab.Thread },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not repeat while the same span stays open', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({
|
||||
isOpen: true,
|
||||
spanId: 'span-1',
|
||||
});
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
|
||||
expect(logTraceEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the same span again after the panel is closed and reopened', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({
|
||||
isOpen: true,
|
||||
spanId: 'span-1',
|
||||
});
|
||||
rerender({ isOpen: false });
|
||||
rerender({ isOpen: true, spanId: 'span-1' });
|
||||
|
||||
expect(openedSpanIds(logTraceEvent)).toStrictEqual(['span-1', 'span-1']);
|
||||
});
|
||||
|
||||
it('reports each span selected while the panel stays open', () => {
|
||||
const { logTraceEvent, rerender } = renderOpenedEvent({
|
||||
isOpen: true,
|
||||
spanId: 'span-1',
|
||||
});
|
||||
rerender({ isOpen: true, spanId: 'span-2' });
|
||||
rerender({ isOpen: true, spanId: 'span-3' });
|
||||
|
||||
expect(openedSpanIds(logTraceEvent)).toStrictEqual([
|
||||
'span-1',
|
||||
'span-2',
|
||||
'span-3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { TraceDetailLogEvent } from './useTraceDetailLogEvent';
|
||||
|
||||
interface SpanDetailsOpenedEventArgs {
|
||||
isOpen: boolean;
|
||||
spanId: string | undefined;
|
||||
detailsTab: TraceDetailsTab;
|
||||
logTraceEvent: TraceDetailLogEvent;
|
||||
}
|
||||
|
||||
// Fires once per span shown in the details panel; resets when the panel closes.
|
||||
export function useSpanDetailsOpenedEvent({
|
||||
isOpen,
|
||||
spanId,
|
||||
detailsTab,
|
||||
logTraceEvent,
|
||||
}: SpanDetailsOpenedEventArgs): void {
|
||||
useEffect(() => {
|
||||
if (!isOpen || !spanId) {
|
||||
return;
|
||||
}
|
||||
logTraceEvent(TraceDetailEvents.SpanDetailsOpened, {
|
||||
[TraceDetailEventKeys.SpanId]: spanId,
|
||||
[TraceDetailEventKeys.DetailsTab]: detailsTab,
|
||||
});
|
||||
}, [isOpen, spanId, detailsTab, logTraceEvent]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef<string>('');
|
||||
const threadDataLoadedFiredForRef = useRef<string>('');
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
@@ -39,7 +40,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
dataLoadedFiredForRef={dataLoadedFiredForRef}
|
||||
/>
|
||||
) : (
|
||||
<TraceDetailsThread />
|
||||
<TraceDetailsThread dataLoadedFiredForRef={threadDataLoadedFiredForRef} />
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
);
|
||||
|
||||
@@ -475,12 +475,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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -182,7 +182,7 @@ func postProcessBuilderQuery[T any](
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
|
||||
// Apply functions
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -234,7 +234,7 @@ func postProcessMetricQuery(
|
||||
reducesToScalar := req.RequestType == qbtypes.RequestTypeScalar && config.ReduceTo != qbtypes.ReduceToUnknown
|
||||
|
||||
if !reducesToScalar {
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
}
|
||||
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -246,7 +246,7 @@ func postProcessMetricQuery(
|
||||
// Apply reduce to for scalar request type
|
||||
if reducesToScalar {
|
||||
result = q.applyMetricReduceTo(result, config.ReduceTo)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
result.Value = convertTimeSeriesDataToScalar(tsData, tsData.QueryName)
|
||||
}
|
||||
@@ -263,7 +263,7 @@ func postProcessTraceOperator(
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
) *qbtypes.Result {
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
|
||||
|
||||
// Apply functions if any
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -296,7 +296,7 @@ func (q *querier) applyMetricReduceTo(result *qbtypes.Result, reduceOp qbtypes.R
|
||||
}
|
||||
|
||||
// applySeriesLimit limits the number of series in the result.
|
||||
func (q *querier) applySeriesLimit(result *qbtypes.Result, limit int, orderBy []qbtypes.OrderBy) *qbtypes.Result {
|
||||
func (q *querier) applySeriesLimit(result *qbtypes.Result, limit, offset int, orderBy []qbtypes.OrderBy) *qbtypes.Result {
|
||||
tsData, ok := result.Value.(*qbtypes.TimeSeriesData)
|
||||
if !ok {
|
||||
return result
|
||||
@@ -305,7 +305,7 @@ func (q *querier) applySeriesLimit(result *qbtypes.Result, limit int, orderBy []
|
||||
if tsData != nil {
|
||||
for _, agg := range tsData.Aggregations {
|
||||
// Use the ApplySeriesLimit function from querybuildertypes
|
||||
agg.Series = qbtypes.ApplySeriesLimit(agg.Series, orderBy, limit)
|
||||
agg.Series = qbtypes.ApplySeriesLimit(agg.Series, orderBy, limit, offset)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Offset, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
@@ -370,7 +370,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
bucketFormulaOutputAsHeatmap(tsData, bucketing)
|
||||
resolveHeatmapBucketAxis(tsData, bucketing)
|
||||
}
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Offset, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
@@ -651,7 +651,7 @@ func (q *querier) processScalarFormula(
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
|
||||
|
||||
// Convert back to scalar format
|
||||
scalarResult := &qbtypes.ScalarData{
|
||||
|
||||
@@ -186,6 +186,7 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
|
||||
name string
|
||||
reduceTo qbtypes.ReduceTo
|
||||
order []qbtypes.OrderBy
|
||||
offset int
|
||||
expected [][]any
|
||||
}{
|
||||
{
|
||||
@@ -211,6 +212,13 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
|
||||
reduceTo: qbtypes.ReduceToMax,
|
||||
expected: [][]any{{"payment", 40.0}, {"auth", 30.0}},
|
||||
},
|
||||
{
|
||||
name: "Max_Desc_Offset1_SkipsHighestPeak",
|
||||
reduceTo: qbtypes.ReduceToMax,
|
||||
order: orderByValue(qbtypes.OrderDirectionDesc),
|
||||
offset: 1,
|
||||
expected: [][]any{{"auth", 30.0}, {"checkout", 10.0}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -220,6 +228,7 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
|
||||
Aggregations: []qbtypes.MetricAggregation{{MetricName: "http_requests_total", ReduceTo: testCase.reduceTo}},
|
||||
Order: testCase.order,
|
||||
Limit: 2,
|
||||
Offset: testCase.offset,
|
||||
}
|
||||
req := &qbtypes.QueryRangeRequest{RequestType: qbtypes.RequestTypeScalar}
|
||||
|
||||
|
||||
17
pkg/querybuilder/offset.go
Normal file
17
pkg/querybuilder/offset.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package querybuilder
|
||||
|
||||
import "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
// AddOffset skips offset rows. Call it after the ORDER BY and LIMIT clauses:
|
||||
// the ClickHouse flavor renders OFFSET only inside a LIMIT, so an unlimited
|
||||
// page writes the clause itself at the current position.
|
||||
func AddOffset(sb *sqlbuilder.SelectBuilder, limit, offset int) {
|
||||
if offset <= 0 {
|
||||
return
|
||||
}
|
||||
if limit > 0 {
|
||||
sb.Offset(offset)
|
||||
return
|
||||
}
|
||||
sb.SQL("OFFSET " + sb.Var(offset))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user