mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-03 23:10:59 +00:00
Compare commits
9 Commits
nv/distr-q
...
ci/e2e-nod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be463e06a | ||
|
|
e11224105c | ||
|
|
8569702161 | ||
|
|
1b8451650a | ||
|
|
a5bfebbc29 | ||
|
|
afbe1ac7a6 | ||
|
|
2103d6c7b7 | ||
|
|
efb2100e69 | ||
|
|
ce44f1a9cf |
15
.github/workflows/e2eci.yaml
vendored
15
.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 }}
|
||||
@@ -40,7 +47,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -60,7 +67,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -98,7 +105,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -155,7 +162,7 @@ jobs:
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
@@ -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:
|
||||
@@ -9565,9 +9562,9 @@ components:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
buckets:
|
||||
description: The ascending upper bounds of the value buckets, set for heatmap
|
||||
and distribution results; each point then carries one count per bound
|
||||
plus a trailing overflow count in values.
|
||||
description: The ascending upper bounds of the value bands, set only for
|
||||
heatmap results; each point then carries one count per bound plus a trailing
|
||||
overflow count in values.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -9722,56 +9719,6 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Distribution:
|
||||
properties:
|
||||
counts:
|
||||
description: One count per bound in meta.buckets plus a trailing overflow
|
||||
count, summed over every step of the requested time range.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
labels:
|
||||
description: The group-by field values that identify this distribution.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5DistributionAggregation:
|
||||
properties:
|
||||
alias:
|
||||
description: The alias of the aggregation, or the internal name __result_N,
|
||||
with N its index, when no alias was given.
|
||||
type: string
|
||||
distributions:
|
||||
description: One distribution per group-by combination.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
|
||||
nullable: true
|
||||
type: array
|
||||
index:
|
||||
description: The zero-based position of the aggregation in the query.
|
||||
type: integer
|
||||
meta:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
description: Display metadata for the aggregation, including the bucket
|
||||
upper bounds the counts are positional against.
|
||||
type: object
|
||||
Querybuildertypesv5DistributionData:
|
||||
properties:
|
||||
aggregations:
|
||||
description: One entry per aggregation of the query. The order is not guaranteed;
|
||||
match entries to aggregations by index.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
queryName:
|
||||
description: The name of the query that produced this result.
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5ExecStats:
|
||||
description: 'Execution cost of the request: rows and bytes scanned and execution
|
||||
time. A measure of cost, not of whether data exists.'
|
||||
@@ -10595,12 +10542,10 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
|
||||
properties:
|
||||
results:
|
||||
description: 'One result per enabled query, typed by the request type: TimeSeriesData
|
||||
for time_series and heatmap, ScalarData for scalar, RawData for raw and
|
||||
DistributionData for distribution requests.'
|
||||
for time_series, ScalarData for scalar and RawData for raw requests.'
|
||||
items: {}
|
||||
nullable: true
|
||||
type: array
|
||||
@@ -10790,9 +10735,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeResponse:
|
||||
description: 'Response from the v5 query range endpoint. The data.results array
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series
|
||||
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
|
||||
distribution requests.'
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series,
|
||||
ScalarData for scalar, or RawData for raw requests.'
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
@@ -10889,7 +10833,6 @@ components:
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
- distribution
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -11001,8 +10944,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
values:
|
||||
description: The per-bucket counts for heatmap results, in the order of
|
||||
meta.buckets plus a trailing overflow count.
|
||||
description: The per-band counts for heatmap results, in the order of meta.buckets
|
||||
plus a trailing overflow count.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
|
||||
@@ -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 {
|
||||
@@ -6941,7 +6937,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
distribution = 'distribution',
|
||||
}
|
||||
/**
|
||||
* The query kind.
|
||||
@@ -10978,7 +10973,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
value?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
*/
|
||||
values?: number[];
|
||||
}
|
||||
@@ -11599,7 +11594,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
@@ -11720,54 +11715,6 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
|
||||
*/
|
||||
counts?: number[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @description The group-by field values that identify this distribution.
|
||||
*/
|
||||
labels?: Querybuildertypesv5LabelDTO[];
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One distribution per group-by combination.
|
||||
*/
|
||||
distributions?: Querybuildertypesv5DistributionDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The zero-based position of the aggregation in the query.
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5DistributionAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query that produced this result.
|
||||
*/
|
||||
queryName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step interval in seconds that was used for each query, keyed by query name.
|
||||
*/
|
||||
@@ -12023,28 +11970,21 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
| (Querybuildertypesv5TimeSeriesDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5ScalarDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5RawDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5DistributionDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
@@ -12178,7 +12118,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
/**
|
||||
|
||||
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) };
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -481,7 +481,8 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
|
||||
|
||||
// Merge values based on type
|
||||
var mergedValue any
|
||||
if resultType.IsBuiltFromTimeSeries() {
|
||||
switch resultType {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
mergedValue = bc.mergeTimeSeriesValues(ctx, buckets)
|
||||
// Raw and Scalar types are not cached, so no merge needed
|
||||
}
|
||||
@@ -628,8 +629,8 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
return true, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
// No aggregations at all means truly empty
|
||||
if len(tsData.Aggregations) == 0 {
|
||||
@@ -664,7 +665,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
return !hasValues, !hasValues && totalSeries > 0
|
||||
}
|
||||
|
||||
default:
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeScalar, qbtypes.RequestTypeTrace:
|
||||
// Raw and scalar data are not cached
|
||||
return true, false
|
||||
}
|
||||
@@ -767,8 +768,8 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
Warnings: result.Warnings,
|
||||
}
|
||||
|
||||
switch {
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
// Trim time series data
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
|
||||
trimmedData := &qbtypes.TimeSeriesData{}
|
||||
@@ -811,7 +812,7 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
trimmedResult.Value = trimmedData
|
||||
}
|
||||
|
||||
default:
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeScalar, qbtypes.RequestTypeTrace:
|
||||
// Don't cache raw or scalar data
|
||||
return nil
|
||||
}
|
||||
@@ -853,8 +854,8 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
filteredData := &qbtypes.TimeSeriesData{
|
||||
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),
|
||||
|
||||
@@ -428,10 +428,10 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
|
||||
// emptyResultFor returns an empty result payload appropriate for the given kind.
|
||||
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
|
||||
var value any
|
||||
switch {
|
||||
case kind.IsBuiltFromTimeSeries():
|
||||
switch kind {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
value = &qbtypes.TimeSeriesData{QueryName: queryName}
|
||||
case kind == qbtypes.RequestTypeScalar:
|
||||
case qbtypes.RequestTypeScalar:
|
||||
value = &qbtypes.ScalarData{QueryName: queryName}
|
||||
default:
|
||||
value = &qbtypes.RawData{QueryName: queryName}
|
||||
@@ -484,7 +484,7 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
kind := q.kind
|
||||
// all metric queries are time series then reduced if required, except
|
||||
// heatmaps, whose statement returns a row per bucket rather than per point
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics && !kind.CountsIntoBuckets() {
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics && kind != qbtypes.RequestTypeHeatmap {
|
||||
kind = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ func labelValue(val any) string {
|
||||
// * Time-series - *qbtypes.TimeSeriesData
|
||||
// * Scalar - *qbtypes.ScalarData
|
||||
// * Raw - *qbtypes.RawData
|
||||
// * Heatmap and distribution - *qbtypes.TimeSeriesData with one count per bucket at each point.
|
||||
// * Distribution- *qbtypes.DistributionData.
|
||||
func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.TimeRange, step qbtypes.Step, queryName string) (any, error) {
|
||||
var (
|
||||
payload any
|
||||
@@ -94,7 +94,7 @@ func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.Ti
|
||||
payload, err = readAsTimeSeries(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
payload, err = readAsScalar(rows, queryName)
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
payload, err = readAsHeatmap(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeTrace, qbtypes.RequestTypeRawStream:
|
||||
payload, err = readAsRaw(rows, queryName)
|
||||
|
||||
@@ -98,15 +98,6 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
|
||||
// Filter out disabled queries
|
||||
typedResults = q.filterDisabledQueries(typedResults, req)
|
||||
|
||||
// Summed only once the bucket axis is final.
|
||||
if req.RequestType == qbtypes.RequestTypeDistribution {
|
||||
for _, result := range typedResults {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
result.Value = tsData.ToDistributionData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply table formatting for UI if requested
|
||||
if req.FormatOptions != nil && req.FormatOptions.FormatTableResultForUI && req.RequestType == qbtypes.RequestTypeScalar {
|
||||
|
||||
@@ -234,7 +225,7 @@ func postProcessMetricQuery(
|
||||
}
|
||||
}
|
||||
|
||||
if req.RequestType.CountsIntoBuckets() && config.HeatmapBucketing != nil {
|
||||
if req.RequestType == qbtypes.RequestTypeHeatmap && config.HeatmapBucketing != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
resolveHeatmapBucketAxis(tsData, *config.HeatmapBucketing)
|
||||
}
|
||||
@@ -369,7 +360,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeHeatmap, qbtypes.RequestTypeDistribution:
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
// The queries a formula reads were run as time series, so what
|
||||
// arrives here is one value per group per timestamp.
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
|
||||
@@ -134,7 +134,9 @@ func newPromqlQuery(
|
||||
}
|
||||
|
||||
func (q *promqlQuery) Fingerprint() string {
|
||||
if !q.requestType.IsBuiltFromTimeSeries() {
|
||||
switch q.requestType {
|
||||
case qbv5.RequestTypeTimeSeries, qbv5.RequestTypeHeatmap:
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -356,7 +358,7 @@ func collectExecStats(began time.Time, statsMu *sync.Mutex, rowsScanned, bytesSc
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
|
||||
if q.requestType.CountsIntoBuckets() {
|
||||
if q.requestType == qbv5.RequestTypeHeatmap {
|
||||
return q.toResultForHeatmap(matrix, warnings, began, statsMu, rowsScanned, bytesScanned)
|
||||
}
|
||||
return q.toResultForTimeSeriesAndScalar(matrix, warnings, began, statsMu, rowsScanned, bytesScanned), nil
|
||||
|
||||
@@ -151,12 +151,12 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
|
||||
preseededResults := make(map[string]any)
|
||||
for _, name := range missingMetricQueries {
|
||||
switch {
|
||||
case req.RequestType.IsBuiltFromTimeSeries():
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
|
||||
case req.RequestType == qbtypes.RequestTypeScalar:
|
||||
case qbtypes.RequestTypeScalar:
|
||||
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
|
||||
case req.RequestType == qbtypes.RequestTypeRaw:
|
||||
case qbtypes.RequestTypeRaw:
|
||||
preseededResults[name] = &qbtypes.RawData{QueryName: name}
|
||||
}
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func (q *querier) buildQueries(
|
||||
continue
|
||||
}
|
||||
requestType := req.RequestType
|
||||
if requestType.CountsIntoBuckets() && spec.Disabled {
|
||||
if requestType == qbtypes.RequestTypeHeatmap && spec.Disabled {
|
||||
// A disabled query in a heatmap request feeds a formula, and the
|
||||
// formula converts time series into heatmap data, so its inputs
|
||||
// run as time series queries.
|
||||
@@ -432,7 +432,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
// Only the enabled query is used to render the heatmap, so bucket
|
||||
// options are only applied to the enabled query.
|
||||
if requestType.CountsIntoBuckets() && !spec.Disabled {
|
||||
if requestType == qbtypes.RequestTypeHeatmap && !spec.Disabled {
|
||||
if err := spec.Aggregations[i].VerifyAndApplyBucketOptions(spec.BucketOptions); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -655,16 +655,16 @@ func (q *querier) run(
|
||||
if result == nil || result.Value == nil {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case result.Type == qbtypes.RequestTypeScalar:
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeScalar:
|
||||
if val, ok := result.Value.(*qbtypes.ScalarData); ok && val != nil {
|
||||
return len(val.Data) != 0
|
||||
}
|
||||
case result.Type == qbtypes.RequestTypeRaw:
|
||||
case qbtypes.RequestTypeRaw:
|
||||
if val, ok := result.Value.(*qbtypes.RawData); ok && val != nil {
|
||||
return len(val.Rows) != 0
|
||||
}
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if val, ok := result.Value.(*qbtypes.TimeSeriesData); ok && val != nil {
|
||||
if len(val.Aggregations) != 0 {
|
||||
anyNonEmpty := false
|
||||
@@ -984,7 +984,8 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
}
|
||||
|
||||
// Merge all fresh results including the first one
|
||||
if merged.Type.IsBuiltFromTimeSeries() {
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
// Pass nil as cached value to ensure proper merging of all fresh results
|
||||
merged.Value = q.mergeTimeSeriesResults(nil, fresh)
|
||||
}
|
||||
@@ -1006,7 +1007,8 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
return merged
|
||||
}
|
||||
|
||||
if merged.Type.IsBuiltFromTimeSeries() {
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
merged.Value = q.mergeTimeSeriesResults(cached.Value.(*qbtypes.TimeSeriesData), fresh)
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ func rewriteQueryForHistogramCTE(requestType qbtypes.RequestType, query qbtypes.
|
||||
})
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() && !requestType.CountsIntoBuckets() {
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() && requestType != qbtypes.RequestTypeHeatmap {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
|
||||
} else {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
@@ -818,7 +818,7 @@ func (b *StatementBuilder) BuildFinalSelect(
|
||||
args = append(args, a...)
|
||||
}
|
||||
|
||||
if requestType.CountsIntoBuckets() {
|
||||
if requestType == qbtypes.RequestTypeHeatmap {
|
||||
return buildHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
return buildAggregationFinalSelect(combined, args, query)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type CloudProviderType struct{ valuer.String }
|
||||
const foundryScriptURL = "https://signoz.io/foundry.sh"
|
||||
|
||||
var (
|
||||
// cloud providers.
|
||||
@@ -14,8 +17,16 @@ var (
|
||||
CloudProviderTypeGCP = CloudProviderType{valuer.NewString("gcp")}
|
||||
|
||||
ErrCodeCloudProviderInvalidInput = errors.MustNewCode("cloud_integration_invalid_cloud_provider")
|
||||
|
||||
foundryTemplateFuncs = template.FuncMap{
|
||||
"shellQuote": func(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type CloudProviderType struct{ valuer.String }
|
||||
|
||||
func NewCloudProvider(provider string) (CloudProviderType, error) {
|
||||
switch provider {
|
||||
case CloudProviderTypeAWS.StringValue():
|
||||
@@ -28,3 +39,11 @@ func NewCloudProvider(provider string) (CloudProviderType, error) {
|
||||
return CloudProviderType{}, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
type foundryTemplateData struct {
|
||||
FoundryScriptURL string
|
||||
Region string
|
||||
IntegrationAccountID string
|
||||
AgentVersion string
|
||||
Credentials *Credentials
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
CloudFormationQuickCreateBaseURL = valuer.NewString("https://%s.console.aws.amazon.com/cloudformation/home")
|
||||
AgentCloudFormationTemplateS3Path = valuer.NewString("https://signoz-integrations.s3.us-east-1.amazonaws.com/aws-quickcreate-template-%s.json")
|
||||
AgentCloudFormationBaseStackName = valuer.NewString("signoz-integration")
|
||||
)
|
||||
var awsCLITemplate = template.Must(template.New("awsCLI").Funcs(foundryTemplateFuncs).Parse(awsCLITemplateStr()))
|
||||
|
||||
type AWSPostableAccountConfig struct {
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
@@ -16,7 +15,7 @@ type AWSPostableAccountConfig struct {
|
||||
}
|
||||
|
||||
type AWSConnectionArtifact struct {
|
||||
ConnectionURL string `json:"connectionUrl" required:"true"`
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
}
|
||||
|
||||
type AWSAccountConfig struct {
|
||||
@@ -105,10 +104,23 @@ type AWSServiceMetricsConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func NewAWSConnectionArtifact(connectionURL string) *AWSConnectionArtifact {
|
||||
return &AWSConnectionArtifact{
|
||||
ConnectionURL: connectionURL,
|
||||
func NewAWSConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, deploymentRegion string) (*AWSConnectionArtifact, error) {
|
||||
data := foundryTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
Region: deploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
var command bytes.Buffer
|
||||
if err := awsCLITemplate.Execute(&command, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AWSConnectionArtifact{
|
||||
CLICommand: command.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewAWSIntegrationConfig(enabledRegions []string, telemetryCollectionStrategy *AWSTelemetryCollectionStrategy) *AWSIntegrationConfig {
|
||||
@@ -167,3 +179,14 @@ func awsOlderIntegrationConfig(cfg *ProviderIntegrationConfig) *IntegrationConfi
|
||||
older.Telemetry = oldTelemetry
|
||||
return older
|
||||
}
|
||||
|
||||
func awsCLITemplateStr() string {
|
||||
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration aws cloudformation \
|
||||
--region {{.Region | shellQuote}} \
|
||||
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
|
||||
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
|
||||
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
|
||||
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
|
||||
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
|
||||
--agent-version {{.AgentVersion | shellQuote}}`
|
||||
}
|
||||
|
||||
@@ -2,37 +2,12 @@ package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
AgentArmTemplateStorePath = "https://signoz-integrations.s3.us-east-1.amazonaws.com/azure-arm-template-%s.json"
|
||||
AgentDeploymentStackName = "signoz-integration"
|
||||
|
||||
// Default values for fixed ARM template parameters.
|
||||
armDefaultRgName = "signoz-integration-rg"
|
||||
armDefaultContainerEnvName = "signoz-integration-agent-env"
|
||||
armDefaultDeploymentEnv = "production"
|
||||
|
||||
// ARM template parameter key names used in both CLI and PowerShell deployment commands.
|
||||
armParamLocation = "location"
|
||||
armParamSignozAPIKey = "signozApiKey"
|
||||
armParamSignozAPIUrl = "signozApiUrl"
|
||||
armParamSignozIngestionURL = "signozIngestionUrl"
|
||||
armParamSignozIngestionKey = "signozIngestionKey"
|
||||
armParamAccountID = "signozIntegrationAccountId"
|
||||
armParamAgentVersion = "signozIntegrationAgentVersion"
|
||||
armParamRgName = "rgName"
|
||||
armParamContainerEnvName = "containerEnvName"
|
||||
armParamDeploymentEnv = "deploymentEnv"
|
||||
|
||||
// command templates.
|
||||
azureCLITemplate = template.Must(template.New("azureCLI").Parse(azureCLITemplateStr()))
|
||||
azurePowerShellTemplate = template.Must(template.New("azurePS").Parse(azurePowerShellTemplateStr()))
|
||||
)
|
||||
var azureCLITemplate = template.Must(template.New("azureCLI").Funcs(foundryTemplateFuncs).Parse(azureCLITemplateStr()))
|
||||
|
||||
type AzureAccountConfig struct {
|
||||
DeploymentRegion string `json:"deploymentRegion" required:"true"`
|
||||
@@ -46,8 +21,7 @@ type UpdatableAzureAccountConfig struct {
|
||||
type AzurePostableAccountConfig = AzureAccountConfig
|
||||
|
||||
type AzureConnectionArtifact struct {
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
CloudPowerShellCommand string `json:"cloudPowerShellCommand" required:"true"`
|
||||
CLICommand string `json:"cliCommand" required:"true"`
|
||||
}
|
||||
|
||||
type AzureServiceConfig struct {
|
||||
@@ -85,36 +59,6 @@ type AzureIntegrationConfig struct {
|
||||
TelemetryCollectionStrategy []*AzureTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// azureTemplateData is the data struct passed to both command templates.
|
||||
// All fields are exported so text/template can access them.
|
||||
type azureTemplateData struct {
|
||||
// Deploy parameter values.
|
||||
TemplateURL string
|
||||
Location string
|
||||
SignozAPIKey string
|
||||
SignozAPIUrl string
|
||||
SignozIngestionURL string
|
||||
SignozIngestionKey string
|
||||
AccountID string
|
||||
AgentVersion string
|
||||
// ARM parameter key names (from package-level vars).
|
||||
StackName string
|
||||
ParamLocation string
|
||||
ParamSignozAPIKey string
|
||||
ParamSignozAPIUrl string
|
||||
ParamSignozIngestionURL string
|
||||
ParamSignozIngestionKey string
|
||||
ParamAccountID string
|
||||
ParamAgentVersion string
|
||||
ParamRgName string
|
||||
ParamContainerEnvName string
|
||||
ParamDeploymentEnv string
|
||||
// Fixed default values.
|
||||
DefaultRgName string
|
||||
DefaultContainerEnvName string
|
||||
DefaultDeploymentEnv string
|
||||
}
|
||||
|
||||
func NewAzureIntegrationConfig(
|
||||
deploymentRegion string,
|
||||
resourceGroups []string,
|
||||
@@ -127,104 +71,32 @@ func NewAzureIntegrationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
func NewAzureConnectionArtifact(
|
||||
accountID valuer.UUID,
|
||||
agentVersion string,
|
||||
creds *Credentials,
|
||||
cfg *AzurePostableAccountConfig,
|
||||
) (*AzureConnectionArtifact, error) {
|
||||
data := azureTemplateData{
|
||||
TemplateURL: fmt.Sprintf(AgentArmTemplateStorePath, agentVersion),
|
||||
Location: cfg.DeploymentRegion,
|
||||
SignozAPIKey: creds.SigNozAPIKey,
|
||||
SignozAPIUrl: creds.SigNozAPIURL,
|
||||
SignozIngestionURL: creds.IngestionURL,
|
||||
SignozIngestionKey: creds.IngestionKey,
|
||||
AccountID: accountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
StackName: AgentDeploymentStackName,
|
||||
ParamLocation: armParamLocation,
|
||||
ParamSignozAPIKey: armParamSignozAPIKey,
|
||||
ParamSignozAPIUrl: armParamSignozAPIUrl,
|
||||
ParamSignozIngestionURL: armParamSignozIngestionURL,
|
||||
ParamSignozIngestionKey: armParamSignozIngestionKey,
|
||||
ParamAccountID: armParamAccountID,
|
||||
ParamAgentVersion: armParamAgentVersion,
|
||||
ParamRgName: armParamRgName,
|
||||
ParamContainerEnvName: armParamContainerEnvName,
|
||||
ParamDeploymentEnv: armParamDeploymentEnv,
|
||||
DefaultRgName: armDefaultRgName,
|
||||
DefaultContainerEnvName: armDefaultContainerEnvName,
|
||||
DefaultDeploymentEnv: armDefaultDeploymentEnv,
|
||||
func NewAzureConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *AzurePostableAccountConfig) (*AzureConnectionArtifact, error) {
|
||||
data := foundryTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
cliCommand, err := newAzureConnectionCLICommand(data)
|
||||
if err != nil {
|
||||
var command bytes.Buffer
|
||||
if err := azureCLITemplate.Execute(&command, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
psCommand, err := newAzureConnectionPowerShellCommand(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AzureConnectionArtifact{
|
||||
CLICommand: cliCommand,
|
||||
CloudPowerShellCommand: psCommand,
|
||||
CLICommand: command.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newAzureConnectionCLICommand(data azureTemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := azureCLITemplate.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func newAzureConnectionPowerShellCommand(data azureTemplateData) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
err := azurePowerShellTemplate.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func azureCLITemplateStr() string {
|
||||
return `az stack sub create \
|
||||
--name {{.StackName}} \
|
||||
--location {{.Location}} \
|
||||
--template-uri {{.TemplateURL}} \
|
||||
--parameters \
|
||||
{{.ParamLocation}}='{{.Location}}' \
|
||||
{{.ParamSignozAPIKey}}='{{.SignozAPIKey}}' \
|
||||
{{.ParamSignozAPIUrl}}='{{.SignozAPIUrl}}' \
|
||||
{{.ParamSignozIngestionURL}}='{{.SignozIngestionURL}}' \
|
||||
{{.ParamSignozIngestionKey}}='{{.SignozIngestionKey}}' \
|
||||
{{.ParamAccountID}}='{{.AccountID}}' \
|
||||
{{.ParamAgentVersion}}='{{.AgentVersion}}' \
|
||||
--action-on-unmanage deleteAll \
|
||||
--deny-settings-mode denyDelete`
|
||||
}
|
||||
|
||||
func azurePowerShellTemplateStr() string {
|
||||
return "New-AzSubscriptionDeploymentStack `\n" +
|
||||
" -Name \"{{.StackName}}\" `\n" +
|
||||
" -Location \"{{.Location}}\" `\n" +
|
||||
" -TemplateUri \"{{.TemplateURL}}\" `\n" +
|
||||
" -TemplateParameterObject @{\n" +
|
||||
" {{.ParamLocation}} = \"{{.Location}}\"\n" +
|
||||
" {{.ParamSignozAPIKey}} = \"{{.SignozAPIKey}}\"\n" +
|
||||
" {{.ParamSignozAPIUrl}} = \"{{.SignozAPIUrl}}\"\n" +
|
||||
" {{.ParamSignozIngestionURL}} = \"{{.SignozIngestionURL}}\"\n" +
|
||||
" {{.ParamSignozIngestionKey}} = \"{{.SignozIngestionKey}}\"\n" +
|
||||
" {{.ParamAccountID}} = \"{{.AccountID}}\"\n" +
|
||||
" {{.ParamAgentVersion}} = \"{{.AgentVersion}}\"\n" +
|
||||
" {{.ParamRgName}} = \"{{.DefaultRgName}}\"\n" +
|
||||
" {{.ParamContainerEnvName}} = \"{{.DefaultContainerEnvName}}\"\n" +
|
||||
" {{.ParamDeploymentEnv}} = \"{{.DefaultDeploymentEnv}}\"\n" +
|
||||
" } `\n" +
|
||||
" -ActionOnUnmanage \"deleteAll\" `\n" +
|
||||
" -DenySettingsMode \"denyDelete\""
|
||||
return `curl -fsSL {{.FoundryScriptURL | shellQuote}} | bash -s -- cloudintegration azure bicep \
|
||||
--region {{.Region | shellQuote}} \
|
||||
--integration-account-id {{.IntegrationAccountID | shellQuote}} \
|
||||
--signoz-api-url {{.Credentials.SigNozAPIURL | shellQuote}} \
|
||||
--signoz-api-key {{.Credentials.SigNozAPIKey | shellQuote}} \
|
||||
--ingestion-url {{.Credentials.IngestionURL | shellQuote}} \
|
||||
--ingestion-key {{.Credentials.IngestionKey | shellQuote}} \
|
||||
--agent-version {{.AgentVersion | shellQuote}}`
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// file is generated by AI
|
||||
package cloudintegrationtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var testTemplateData = azureTemplateData{
|
||||
TemplateURL: fmt.Sprintf(AgentArmTemplateStorePath, "v0.1.0"),
|
||||
Location: "eastus",
|
||||
SignozAPIKey: "test-api-key",
|
||||
SignozAPIUrl: "https://signoz.example.com",
|
||||
SignozIngestionURL: "https://ingest.example.com",
|
||||
SignozIngestionKey: "test-ingest-key",
|
||||
AccountID: "acct-123",
|
||||
AgentVersion: "v0.1.0",
|
||||
StackName: AgentDeploymentStackName,
|
||||
ParamLocation: armParamLocation,
|
||||
ParamSignozAPIKey: armParamSignozAPIKey,
|
||||
ParamSignozAPIUrl: armParamSignozAPIUrl,
|
||||
ParamSignozIngestionURL: armParamSignozIngestionURL,
|
||||
ParamSignozIngestionKey: armParamSignozIngestionKey,
|
||||
ParamAccountID: armParamAccountID,
|
||||
ParamAgentVersion: armParamAgentVersion,
|
||||
ParamRgName: armParamRgName,
|
||||
ParamContainerEnvName: armParamContainerEnvName,
|
||||
ParamDeploymentEnv: armParamDeploymentEnv,
|
||||
DefaultRgName: armDefaultRgName,
|
||||
DefaultContainerEnvName: armDefaultContainerEnvName,
|
||||
DefaultDeploymentEnv: armDefaultDeploymentEnv,
|
||||
}
|
||||
|
||||
func TestNewAzureConnectionCLICommand(t *testing.T) {
|
||||
cmd, err := newAzureConnectionCLICommand(testTemplateData)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"az stack sub create",
|
||||
fmt.Sprintf("--name %s", AgentDeploymentStackName),
|
||||
fmt.Sprintf("--location %s", testTemplateData.Location),
|
||||
fmt.Sprintf("--template-uri %s", testTemplateData.TemplateURL),
|
||||
fmt.Sprintf("%s='%s'", armParamLocation, testTemplateData.Location),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozAPIKey, testTemplateData.SignozAPIKey),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozAPIUrl, testTemplateData.SignozAPIUrl),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozIngestionURL, testTemplateData.SignozIngestionURL),
|
||||
fmt.Sprintf("%s='%s'", armParamSignozIngestionKey, testTemplateData.SignozIngestionKey),
|
||||
fmt.Sprintf("%s='%s'", armParamAccountID, testTemplateData.AccountID),
|
||||
fmt.Sprintf("%s='%s'", armParamAgentVersion, testTemplateData.AgentVersion),
|
||||
"--action-on-unmanage deleteAll",
|
||||
"--deny-settings-mode denyDelete",
|
||||
}
|
||||
|
||||
for _, fragment := range mustContain {
|
||||
if !strings.Contains(cmd, fragment) {
|
||||
t.Errorf("CLI command missing %q\ngot:\n%s", fragment, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Lines must be joined with the bash line-continuation separator.
|
||||
if !strings.Contains(cmd, " \\\n") {
|
||||
t.Errorf("CLI command missing line-continuation separator ' \\\\\\n'\ngot:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAzureConnectionPowerShellCommand(t *testing.T) {
|
||||
cmd, err := newAzureConnectionPowerShellCommand(testTemplateData)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
mustContain := []string{
|
||||
"New-AzSubscriptionDeploymentStack",
|
||||
fmt.Sprintf("-Name \"%s\"", AgentDeploymentStackName),
|
||||
fmt.Sprintf("-Location \"%s\"", testTemplateData.Location),
|
||||
fmt.Sprintf("-TemplateUri \"%s\"", testTemplateData.TemplateURL),
|
||||
armParamLocation,
|
||||
armParamSignozAPIKey,
|
||||
armParamSignozAPIUrl,
|
||||
armParamSignozIngestionURL,
|
||||
armParamSignozIngestionKey,
|
||||
armParamAccountID,
|
||||
armParamAgentVersion,
|
||||
armParamRgName,
|
||||
armParamContainerEnvName,
|
||||
armParamDeploymentEnv,
|
||||
armDefaultRgName,
|
||||
armDefaultContainerEnvName,
|
||||
armDefaultDeploymentEnv,
|
||||
"-ActionOnUnmanage \"deleteAll\"",
|
||||
"-DenySettingsMode \"denyDelete\"",
|
||||
}
|
||||
|
||||
for _, fragment := range mustContain {
|
||||
if !strings.Contains(cmd, fragment) {
|
||||
t.Errorf("PowerShell command missing %q\ngot:\n%s", fragment, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Final command must not end with a line-continuation backtick.
|
||||
if strings.HasSuffix(strings.TrimSpace(cmd), "`") {
|
||||
t.Errorf("PowerShell command must not end with a backtick\ngot:\n%s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAzureConnectionArtifact(t *testing.T) {
|
||||
accountID := valuer.GenerateUUID()
|
||||
agentVersion := "v0.1.0"
|
||||
creds := &Credentials{
|
||||
SigNozAPIURL: "https://signoz.example.com",
|
||||
SigNozAPIKey: "test-api-key",
|
||||
IngestionURL: "https://ingest.example.com",
|
||||
IngestionKey: "test-ingest-key",
|
||||
}
|
||||
cfg := &AzurePostableAccountConfig{
|
||||
DeploymentRegion: "eastus",
|
||||
ResourceGroups: []string{"rg1"},
|
||||
}
|
||||
|
||||
artifact, err := NewAzureConnectionArtifact(accountID, agentVersion, creds, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if artifact.CLICommand == "" {
|
||||
t.Error("CLICommand must not be empty")
|
||||
}
|
||||
if artifact.CloudPowerShellCommand == "" {
|
||||
t.Error("CloudPowerShellCommand must not be empty")
|
||||
}
|
||||
if !strings.Contains(artifact.CLICommand, accountID.StringValue()) {
|
||||
t.Errorf("CLICommand must contain accountID %q", accountID.StringValue())
|
||||
}
|
||||
if !strings.Contains(artifact.CloudPowerShellCommand, accountID.StringValue()) {
|
||||
t.Errorf("CloudPowerShellCommand must contain accountID %q", accountID.StringValue())
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,7 @@ var (
|
||||
GCPConnectionMethodManual = GCPConnectionMethod{valuer.NewString("manual")}
|
||||
GCPConnectionMethodAgent = GCPConnectionMethod{valuer.NewString("agent")}
|
||||
|
||||
foundryScriptURL = "https://signoz.io/foundry.sh"
|
||||
|
||||
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(template.FuncMap{
|
||||
"shellQuote": func(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
},
|
||||
}).Parse(gcpCLITemplateStr()))
|
||||
gcpCLITemplate = template.Must(template.New("gcpCLI").Funcs(foundryTemplateFuncs).Parse(gcpCLITemplateStr()))
|
||||
)
|
||||
|
||||
type GCPConnectionMethod struct{ valuer.String }
|
||||
@@ -112,12 +106,14 @@ func (config *GCPAccountConfig) ErrIfInvalid() error {
|
||||
|
||||
func NewGCPConnectionArtifact(integrationAccountID valuer.UUID, agentVersion string, creds *Credentials, cfg *GCPAccountConfig) (*GCPConnectionArtifact, error) {
|
||||
data := gcpTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
ProjectID: cfg.DeploymentProjectID,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
foundryTemplateData: foundryTemplateData{
|
||||
FoundryScriptURL: foundryScriptURL,
|
||||
Region: cfg.DeploymentRegion,
|
||||
IntegrationAccountID: integrationAccountID.StringValue(),
|
||||
AgentVersion: agentVersion,
|
||||
Credentials: creds,
|
||||
},
|
||||
ProjectID: cfg.DeploymentProjectID,
|
||||
}
|
||||
|
||||
var command bytes.Buffer
|
||||
@@ -158,12 +154,8 @@ func NewGCPIntegrationConfig(config *GCPAccountConfig, metricNames, sinkFilters
|
||||
}
|
||||
|
||||
type gcpTemplateData struct {
|
||||
FoundryScriptURL string
|
||||
ProjectID string
|
||||
Region string
|
||||
IntegrationAccountID string
|
||||
AgentVersion string
|
||||
Credentials *Credentials
|
||||
foundryTemplateData
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
func gcpCLITemplateStr() string {
|
||||
|
||||
@@ -526,7 +526,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateBucketCounting
|
||||
// TestHeatmapPanelQueryKinds pins the panel allowlist to what validateHeatmap
|
||||
// accepts in querybuildertypesv5: everything but a trace operator.
|
||||
func TestHeatmapPanelQueryKinds(t *testing.T) {
|
||||
testCases := []struct {
|
||||
|
||||
@@ -81,7 +81,7 @@ func (b *BucketOptions) ToHeatmapBucketing() HeatmapBucketing {
|
||||
return resolved
|
||||
}
|
||||
|
||||
// This cannot be called in validateBucketCounting cuz metric type is resolved in querier.go.
|
||||
// This cannot be called in validateHeatmap cuz metric type is resolved in querier.go.
|
||||
func (a *MetricAggregation) VerifyAndApplyBucketOptions(bucketOptions *BucketOptions) error {
|
||||
switch a.Type {
|
||||
case metrictypes.HistogramType:
|
||||
@@ -298,31 +298,6 @@ func addHeatmapBucketsWithNoCountsForAggregation(aggBucket *AggregationBucket, b
|
||||
aggBucket.Meta.Buckets = denseUpperBounds
|
||||
}
|
||||
|
||||
// ToDistributionData sums every series over all its steps, keeping the bucket
|
||||
// axis. A partial step counts too, since what it holds was observed.
|
||||
func (t *TimeSeriesData) ToDistributionData() *DistributionData {
|
||||
data := &DistributionData{QueryName: t.QueryName, Aggregations: make([]*DistributionAggregation, 0, len(t.Aggregations))}
|
||||
for _, aggBucket := range t.Aggregations {
|
||||
if aggBucket == nil {
|
||||
continue
|
||||
}
|
||||
aggregation := &DistributionAggregation{Index: aggBucket.Index, Alias: aggBucket.Alias, Meta: aggBucket.Meta, Distributions: make([]*Distribution, 0, len(aggBucket.Series))}
|
||||
for _, series := range aggBucket.Series {
|
||||
counts := make([]float64, len(aggBucket.Meta.Buckets)+1)
|
||||
for _, point := range series.Values {
|
||||
for i, count := range point.Values {
|
||||
if i < len(counts) {
|
||||
counts[i] += count
|
||||
}
|
||||
}
|
||||
}
|
||||
aggregation.Distributions = append(aggregation.Distributions, &Distribution{Labels: series.Labels, Counts: counts})
|
||||
}
|
||||
data.Aggregations = append(data.Aggregations, aggregation)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// calculateIndexOfUpperBound and calculateUpperBoundAtIndex are inverses over
|
||||
// the axis being returned, so they read h.LogScale rather than the MaxLogScale
|
||||
// ClickHouse bucketed at: k * maxValue / numBuckets on a linear axis,
|
||||
|
||||
@@ -39,8 +39,7 @@ var (
|
||||
RequestTypeRawStream = RequestType{valuer.NewString("raw_stream")}
|
||||
// [][]any, Specialized SQL result set, paginated.
|
||||
RequestTypeTrace = RequestType{valuer.NewString("trace")}
|
||||
// DistributionData: a heatmap summed over its steps, one set of counts per
|
||||
// group against the aggregation's bucket upper bounds, example: histogram.
|
||||
// []Bucket (struct{Lower,Upper,Count float64}), example: histogram.
|
||||
RequestTypeDistribution = RequestType{valuer.NewString("distribution")}
|
||||
// TimeSeriesData carrying one count per histogram bucket at each timestamp,
|
||||
// with the shared bucket upper bounds on the aggregation's meta.
|
||||
@@ -56,14 +55,6 @@ func (r RequestType) IsAggregation() bool {
|
||||
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution || r == RequestTypeHeatmap
|
||||
}
|
||||
|
||||
func (r RequestType) CountsIntoBuckets() bool {
|
||||
return r == RequestTypeHeatmap || r == RequestTypeDistribution
|
||||
}
|
||||
|
||||
func (r RequestType) IsBuiltFromTimeSeries() bool {
|
||||
return r == RequestTypeTimeSeries || r.CountsIntoBuckets()
|
||||
}
|
||||
|
||||
// Enum implements jsonschema.Enum; returns the acceptable values for RequestType.
|
||||
func (RequestType) Enum() []any {
|
||||
return []any{
|
||||
@@ -73,6 +64,6 @@ func (RequestType) Enum() []any {
|
||||
RequestTypeRawStream,
|
||||
RequestTypeTrace,
|
||||
RequestTypeHeatmap,
|
||||
RequestTypeDistribution,
|
||||
// RequestTypeDistribution,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ type QueryWarnDataAdditional struct {
|
||||
}
|
||||
|
||||
type QueryData struct {
|
||||
Results []any `json:"results" description:"One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests."`
|
||||
Results []any `json:"results" description:"One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests."`
|
||||
}
|
||||
|
||||
var _ jsonschema.OneOfExposer = QueryData{}
|
||||
@@ -53,7 +53,6 @@ func (QueryData) JSONSchemaOneOf() []any {
|
||||
TimeSeriesData{},
|
||||
ScalarData{},
|
||||
RawData{},
|
||||
DistributionData{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +128,7 @@ var _ jsonschema.Preparer = &QueryRangeResponse{}
|
||||
|
||||
// PrepareJSONSchema adds description to the QueryRangeResponse schema.
|
||||
func (q *QueryRangeResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.")
|
||||
schema.WithDescription("Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -259,7 +258,7 @@ func (a *AggregationBucket) TrimAxisToCountedBuckets() {
|
||||
|
||||
type AggregationMeta struct {
|
||||
Unit string `json:"unit,omitempty" description:"The unit of the aggregated values, taken from metric metadata when known."`
|
||||
Buckets []float64 `json:"buckets,omitempty" description:"The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values."`
|
||||
Buckets []float64 `json:"buckets,omitempty" description:"The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values."`
|
||||
}
|
||||
|
||||
type TimeSeries struct {
|
||||
@@ -376,26 +375,7 @@ type TimeSeriesValue struct {
|
||||
|
||||
Partial bool `json:"partial,omitempty" description:"True when the step is only partly covered by the requested time range, so the value is incomplete."`
|
||||
|
||||
Values []float64 `json:"values,omitempty" description:"The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count."`
|
||||
}
|
||||
|
||||
// DistributionData is a heatmap summed over its steps: one set of counts per
|
||||
// group, positional against the aggregation's bucket upper bounds.
|
||||
type DistributionData struct {
|
||||
QueryName string `json:"queryName" description:"The name of the query that produced this result."`
|
||||
Aggregations []*DistributionAggregation `json:"aggregations" description:"One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index."`
|
||||
}
|
||||
|
||||
type DistributionAggregation struct {
|
||||
Index int `json:"index" description:"The zero-based position of the aggregation in the query."`
|
||||
Alias string `json:"alias" description:"The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given."`
|
||||
Meta AggregationMeta `json:"meta,omitempty" description:"Display metadata for the aggregation, including the bucket upper bounds the counts are positional against."`
|
||||
Distributions []*Distribution `json:"distributions" description:"One distribution per group-by combination."`
|
||||
}
|
||||
|
||||
type Distribution struct {
|
||||
Labels []*Label `json:"labels,omitempty" description:"The group-by field values that identify this distribution."`
|
||||
Counts []float64 `json:"counts" description:"One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range."`
|
||||
Values []float64 `json:"values,omitempty" description:"The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count."`
|
||||
}
|
||||
|
||||
type ColumnType struct {
|
||||
|
||||
@@ -584,7 +584,7 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
@@ -592,11 +592,11 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap, distribution",
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap",
|
||||
)
|
||||
}
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -637,14 +637,14 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap, distribution")
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
|
||||
}
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
if err := r.validateHeatmap(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -865,14 +865,14 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
if !r.RequestType.CountsIntoBuckets() {
|
||||
func (r *QueryRangeRequest) validateHeatmap() error {
|
||||
if r.RequestType != RequestTypeHeatmap {
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
if extractEnabledBucketOptions(envelope) != nil {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"bucketOptions are only supported for heatmap and distribution requests, got %s",
|
||||
r.RequestType.StringValue(),
|
||||
"bucketOptions are only supported for heatmap requests, got %s",
|
||||
r.RequestType,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -881,7 +881,7 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
|
||||
if r.FormatOptions != nil && r.FormatOptions.FillGaps {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"fillGaps is not supported for %s requests: an absent column means collection stopped, which a zero-filled column would hide", r.RequestType.StringValue())
|
||||
"fillGaps is not supported for heatmap requests: an absent column means collection stopped, which a zero-filled column would hide")
|
||||
}
|
||||
|
||||
enabled := 0
|
||||
@@ -892,7 +892,7 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
|
||||
switch spec := envelope.Spec.(type) {
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
if err := validateBucketCountingQuery(r.RequestType, spec.Functions, spec.Having); err != nil {
|
||||
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
@@ -900,7 +900,7 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
}
|
||||
enabled++
|
||||
case QueryBuilderFormula:
|
||||
if err := validateBucketCountingQuery(r.RequestType, spec.Functions, spec.Having); err != nil {
|
||||
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Disabled {
|
||||
@@ -925,7 +925,7 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
"heatmaps are not supported for the logs and traces signals yet")
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"%s requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", r.RequestType.StringValue(), envelope.Type.StringValue())
|
||||
"heatmap requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", envelope.Type.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,11 +937,11 @@ func (r *QueryRangeRequest) validateBucketCounting() error {
|
||||
"at least one query is required")
|
||||
case enabled == 0:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a %s needs one enabled query, but every query is disabled", r.RequestType.StringValue()).
|
||||
"a heatmap needs one enabled query, but every query is disabled").
|
||||
WithAdditional("Enable the query whose distribution you want to plot")
|
||||
case enabled > 1:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"%s requests take one enabled query, but %d are enabled", r.RequestType.StringValue(), enabled).
|
||||
"a heatmap renders one distribution, but %d queries are enabled", enabled).
|
||||
WithAdditional(
|
||||
"Disable the queries you don't want to plot, keeping only the one whose distribution you want to show",
|
||||
"A formula can stay enabled with the queries it reads disabled",
|
||||
@@ -1011,19 +1011,18 @@ func (b *BucketOptions) validateBucketOptions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBucketCountingQuery refuses the per-query settings that cannot mean
|
||||
// anything once values are counted into buckets. It runs on disabled queries
|
||||
// too: a disabled query is a formula input, so whatever it does still reaches
|
||||
// the counts.
|
||||
func validateBucketCountingQuery(requestType RequestType, functions []Function, having *Having) error {
|
||||
// validateHeatmapQuery refuses the per-query settings that cannot mean anything
|
||||
// on a heatmap. It runs on disabled queries too: a disabled query is a formula
|
||||
// input, so whatever it does still reaches the cells.
|
||||
func validateHeatmapQuery(functions []Function, having *Having) error {
|
||||
if len(functions) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"functions are not supported for %s requests: a heatmap point is a count per bucket, not a single value", requestType.StringValue())
|
||||
"functions are not supported for heatmap requests: a heatmap point is a count per bucket, not a single value")
|
||||
}
|
||||
|
||||
if having != nil && having.Expression != "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"having is not supported for %s requests: it filters individual cells, which breaks the cumulative differencing", requestType.StringValue())
|
||||
"having is not supported for heatmap requests: it filters individual cells, which breaks the cumulative differencing")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1031,7 +1030,7 @@ func validateBucketCountingQuery(requestType RequestType, functions []Function,
|
||||
|
||||
func GetValidationOptions(requestType RequestType) []ValidationOption {
|
||||
switch requestType {
|
||||
case RequestTypeTimeSeries, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
case RequestTypeTimeSeries, RequestTypeHeatmap:
|
||||
return []ValidationOption{WithSkipSelectFieldValidation(), WithTimestampGroupByValidation()}
|
||||
case RequestTypeScalar:
|
||||
return []ValidationOption{WithSkipSelectFieldValidation(), WithReduceToValidation()}
|
||||
|
||||
9
tests/e2e/drivers/channels.ts
generated
9
tests/e2e/drivers/channels.ts
generated
@@ -209,6 +209,10 @@ interface ChannelForm {
|
||||
/** Saves an existing channel and waits for the write to land. */
|
||||
saveEdit(): Promise<void>;
|
||||
save(): Promise<void>;
|
||||
/** The sticky bar holding discard, test and save. */
|
||||
actions(): Locator;
|
||||
/** Scrolls with the mouse wheel, which unlike scrollIntoView cannot move an overflow: hidden box. */
|
||||
wheelToEnd(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,6 +338,11 @@ function channelsDriver(page: Page): Channels {
|
||||
async save() {
|
||||
await page.getByTestId('save-channel-button').click();
|
||||
},
|
||||
actions: () => page.getByTestId('discard-channel-button').locator('..'),
|
||||
async wheelToEnd() {
|
||||
await form.name().hover();
|
||||
await page.mouse.wheel(0, 10_000);
|
||||
},
|
||||
};
|
||||
|
||||
const api: ChannelApi = {
|
||||
|
||||
37
tests/e2e/drivers/integrations.ts
generated
37
tests/e2e/drivers/integrations.ts
generated
@@ -210,15 +210,13 @@ export interface AwsFlow {
|
||||
toggleRegion(code: string): Promise<void>;
|
||||
/** Fill whichever credential inputs the credentials endpoint left empty. */
|
||||
fillMissingCredentials(): Promise<void>;
|
||||
/**
|
||||
* Submit the add-account drawer. `window.open` is replaced first, so no AWS
|
||||
* console tab opens; the URL it was called with is returned instead.
|
||||
*/
|
||||
launch(): Promise<{
|
||||
/** Submit the add-account drawer. Resolves the agent setup command it returned. */
|
||||
generateCommand(): Promise<{
|
||||
accountId: string;
|
||||
connectionUrl: string;
|
||||
command: string;
|
||||
body: { config?: { aws?: { regions?: string[] } } };
|
||||
}>;
|
||||
agentCommand(): Locator;
|
||||
/** Set exactly `codes` in the edit drawer and press Update Changes. */
|
||||
updateRegions(codes: string[]): Promise<Response>;
|
||||
}
|
||||
@@ -492,34 +490,23 @@ export function cloudDriver(page: Page, provider: CloudProvider): Cloud {
|
||||
}
|
||||
},
|
||||
|
||||
launch: async () => {
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
open: (url?: string) => null;
|
||||
__e2eOpened?: string;
|
||||
};
|
||||
w.open = (url?: string): null => {
|
||||
w.__e2eOpened = String(url ?? '');
|
||||
return null;
|
||||
};
|
||||
});
|
||||
generateCommand: async () => {
|
||||
const created = page.waitForResponse(isCall('POST', `${base}/accounts`));
|
||||
await page.getByTestId('aws-launch-cfn-btn').click();
|
||||
await page.getByTestId('aws-generate-command-btn').click();
|
||||
const response = await created;
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
const { data } = (await response.json()) as { data: { id: string } };
|
||||
const connectionUrl = await page
|
||||
.waitForFunction(
|
||||
() => (window as unknown as { __e2eOpened?: string }).__e2eOpened,
|
||||
)
|
||||
.then((handle) => handle.jsonValue() as Promise<string>);
|
||||
const { data } = (await response.json()) as {
|
||||
data: { id: string; connectionArtifact: { aws: { cliCommand: string } } };
|
||||
};
|
||||
return {
|
||||
accountId: data.id,
|
||||
connectionUrl,
|
||||
command: data.connectionArtifact.aws.cliCommand,
|
||||
body: response.request().postDataJSON(),
|
||||
};
|
||||
},
|
||||
|
||||
agentCommand: () => page.getByTestId('aws-agent-command'),
|
||||
|
||||
updateRegions: async (codes) => {
|
||||
const boxes = page.locator('[data-testid^="aws-region-"]');
|
||||
await expect(boxes.first()).toBeVisible();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user