Compare commits

..

9 Commits

Author SHA1 Message Date
Ashwin Bhatkal
3be463e06a ci(e2e): pin Node 22 2026-11-03 22:58:22 +05:30
Ashwin Bhatkal
e11224105c ci(e2e): run the suite on 10 shards 2026-11-03 22:58:21 +05:30
Ashwin Bhatkal
8569702161 test(query-builder): stop the QueryV2 Delete menu test timing out in CI (#13394)
#### Description

- The "shows Delete with more than one query and deletes on click" test
from [#13189](https://github.com/SigNoz/signoz/pull/13189) hits jest's 5
s timeout in CI. It has failed most `jsci` runs since it merged,
including merge-queue runs for unrelated PRs.
- It rendered the whole `QueryBuilderV2` with two queries only to make
the Delete item show. It now renders one `QueryV2` with
`queriesCount={2}`, which takes it from about 0.8 s to 0.3 s locally.
- The query actions menu tests find the trigger and items by the test
ids `Dropdown` already sets, in place of a class selector and role
lookups.
- The Delete test keeps a 15 s timeout as headroom for CI load.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/685
2026-11-03 17:06:32 +00:00
Vikrant Gupta
1b8451650a docs(go): require descriptive receiver names (#13391)
#### Description

- The packages guide asked for one- or two-character receivers, which is
what the older half of the codebase does; the newer layers use the
type's role name (`provider`, `module`, `handler`, `store`). The guide
now requires the latter.
- Settles the import-shadowing case the same way
`signozapiserver/registry.go` already does: alias the import, keep the
descriptive receiver.
2026-11-03 16:08:29 +00:00
Swapnil Nakade
a5bfebbc29 refactor: using foundry commands for aws and azure integration (#13270)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- moving aws and azure integrations to use foundry commands for
integration connection initiation

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
contributes to https://github.com/keystone-pod/issues/167
2026-11-03 14:44:21 +00:00
Gaurav Tewari
afbe1ac7a6 feat: add events for span detail open and trace view (#13358)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- New `Trace Detail: Span details opened` event, fired once per span
shown in the details panel (click or `spanId` deep link) on both the
Overview and Thread tabs.
- Thread tab now fires the existing `Trace Detail: Data loaded` event,
with the trace-level attributes it has: `totalSpansCount`,
`traceDurationMs`, `hadErrors`, `spanPanelVariant`.
- Both events carry `detailsTab` (`overview` | `thread`) so the tabs can
be split in analytics.

#### Issues closed by this PR
Closes SigNoz/nerve-pod#581

#### Screenshots / Screen Recordings
Before - 


https://github.com/user-attachments/assets/ef1c188c-898b-4a46-b113-39215d5e93d1


Now - 


https://github.com/user-attachments/assets/844dbb67-343c-4cd1-9947-d3c9c40a5e62



#### Additional Information
- Named `detailsTab`, not `tab`: `tab` already means the span panel's
tab on other trace-detail events.
- Data loaded fires once per trace per tab; the guard ref lives in the
page, so revisiting a tab doesn't re-fire it.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-03 14:10:53 +00:00
ashu8912
2103d6c7b7 fix(notification-channels): scroll channel forms inside configuration and pin actions to the bottom (#13382)
#### Description

- New and edit channel forms now scroll inside the configuration pane,
with Discard / Test / Save pinned to the bottom. Before, the pane's
hidden overflow cut the form off, so the bottom fields and the actions
could not be reached on shorter screens.
- Playwright spec opens both forms in a 600px-tall viewport, wheels to
the end and checks the last control is reachable and sits above the
pinned actions. It fails without the fix.
- The existing ChannelsNew and ChannelsEdit stories already render these
pages.
2026-11-03 13:08:24 +00:00
ashu8912
efb2100e69 fix(alerts): papercut fixes for routing policies and planned downtime tables (#13320)
#### Description

- Page titles now read "All routing policies" / "All planned downtimes"
and the create buttons are title-cased, matching Notification Channels.
- Channels and Alerts Silenced show as many chips as fit the cell, then
a "+N" chip that lists the rest on hover; a long name truncates with an
ellipsis. Dropped the native `title` that doubled the badge tooltip.
- Resizing a column now moves only its right edge. The name column no
longer fills; the column before Actions does, via a new opt-in
`passLastColumnFill` on the shared table, so other tables are unchanged.
- Channels is now the second column in Routing Policies.
- Stories updated: the first routing policy and downtime now overflow
into "+N" in their Default stories.

#### Additional Information

- Truncation sets `--badge-max-width` on the cell, because the global
override in `styles.scss` outranks Badge's `maxWidth` prop.
- Notification Channels has the same column-fill setup and likely the
same resize bug; not changed here.

Closes: https://github.com/SigNoz/pulse-pod/issues/670
2026-11-03 12:50:21 +00:00
Ashwin Bhatkal
ce44f1a9cf ci(e2e): report the e2e check on merge queue runs (#13381)
#### Description

- `e2eci` now also runs in the merge queue, so the `e2e` check is
reported on merge queue commits.
- In the queue, a `merge-queue-pass` step posts a notice, the e2e jobs
are skipped, and the `e2e` check passes. E2E has already gated the PR by
then, the same way integration tests run on PRs and never in the queue.
- Without this, making `e2e` a required check stalls the queue. Merge
queue commits never get an `e2e` check, so every PR waits out the
60-minute timeout and is removed with `checks_timed_out`. This happened
on 2026-11-02 until `e2e` was made optional again.
- Once this merges, `e2e` can be required again. This PR's own merge
queue run should show a passing `e2e` check on its
`gh-readonly-queue/...` commit.
2026-11-03 12:43:35 +00:00
107 changed files with 2710 additions and 1726 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,3 +3,7 @@
align-items: center;
gap: var(--spacing-4);
}
.mono {
composes: mono from '../shared.module.scss';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -22,6 +22,7 @@ const renderPolling = (): void => {
renderHook(
() =>
useAgentConnectionPolling({
cloudProvider: 'gcp',
accountId: ACCOUNT_ID,
enabled: true,
...callbacks,

View File

@@ -21,6 +21,7 @@ const renderPolling = (
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useAgentConnectionPolling({
cloudProvider: 'gcp',
accountId: ACCOUNT_ID,
enabled,
...callbacks,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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