Compare commits

...

13 Commits

Author SHA1 Message Date
Naman Verma
4813da9556 feat: add support for offset in all query range request types 2026-11-05 10:38:41 +05:30
Vinicius Lourenço
9d465d54f5 test(e2e): retry clicks swallowed by dnd-kit and loading antd buttons (#13401)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (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

- `app-shell/sidenav` TC-03: dnd-kit stops every click for 50 ms after a
drop, so the Save click that follows the drag was sometimes lost and no
preference request went out. `saveShortcuts` now clicks again while the
dialog is still open.
- `messaging-queues/kafka` TC-01: the health check button is a `loading`
antd `Button` while its onboarding queries fetch, and a loading antd
button ignores clicks without being disabled. `openHealthCheck` now
clicks again until the dialog opens.
- Both tests came in with #13189 and have been failing or needing
retries in CI on unrelated PRs. Notes added to
`tests/e2e/docs/flake-notes.md`.
2026-11-03 19:13:45 +00:00
Abhi kumar
7d95fa7b25 fix(query-builder): added query follows the previous query's signal (#13323)
#### Description

- In the V2 panel editor, "Add query" always created a Metrics query:
`createNewBuilderQuery` fell back to `DataSource.METRICS` because the
panel editor passes no fixed `initialDataSource`. A logs or traces panel
got a metrics query B.
- The new query now continues the last query's signal (and its `source`,
so a meter query stays meter). Hosts with a fixed data source
(explorers, alerts) are unchanged.
- No story adds a query, so no story renders differently.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#659

#### Additional Information

- Stacked on #13315; #13313 (query builder e2e) sits on top and covers
this with a regression test.
2026-11-03 17:47:48 +00:00
Abhi kumar
cd1068e1c3 fix(query-builder): legacy having casing and non-positive limits (#13315)
#### Description

- **Legacy having:** the old having filter stored `COUNT()` (upper-cased
function). `convertHavingToExpression` passed it through and the v5
backend rejects it with a 400 (`Invalid references in Having expression:
[COUNT]`), so any saved legacy query with a having failed to run. Only
the function name is now lower-cased; attribute keys keep their case.
- **Limit:** a negative limit was sent as-is and the backend returned a
400. The query and formula Limit inputs now accept digits only (`-10`
shows as `10`), so the shown value is always what's sent. Empty or `0`
means no limit.
- `InputWithLabel` gains an optional `sanitize` prop for this.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#657

#### Additional Information

- Found by the query builder e2e suite (#13313), where both cases are
`test.fixme` until this merges. The negative-limit case should expect
the sign to be dropped.
2026-11-03 17:43:47 +00:00
Nityananda Gohain
dca68d89bc feat(stats): report AI observability usage from the stats endpoint (#13359)
#### Description

 New stats from `/api/v1/stats`:

- `telemetry.traces.gen_ai.request.model.exists` – a span carried a
request model since the start of the previous day.
  - `telemetry.traces.gen_ai.tool.name.exists` – same, for a tool name.
- `telemetry.traces.gen_ai.agent.name.exists` – same, for an agent name.
- `llmpricingrule.custom.count` – pricing rules users added themselves.
  - `llmpricingrule.override.count` – synced default rules users pinned.
- `llmpricingrule.unmapped_model.count` – models seen in that window
that no rule prices.
  - `dashboard.panels.ai.count` – panels with an AI builder query.
 
The gen_ai values come from the traces tag table through the metadata
store, never from a span scan. The model list is capped at 100 names,
which is more than any org is expected to use in two days; past the cap
the unmapped count is a lower bound.

Existing stats change: every `dashboard.*` count now skips system
dashboards, and AI queries no longer count under
`dashboard.panels.traces.count`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/nerve-pod/issues/577

  #### Additional Information

- `/api/v1/stats` latency against `main` on the same ClickHouse with
200k spans: sequential p50 14 ms → 21 ms, at 10 concurrent callers p50
78 ms → 101 ms. The three lookups run concurrently and cost about 8 ms
each regardless of limit.
- If ClickHouse is unreachable the rule counts are still reported and
only the gen_ai keys are omitted, so the reporter keeps their last
value.
2026-11-03 17:15:15 +00:00
Vishal Sharma
1be806c58d fix(ai-assistant): open explorer chips in the view the query implies (#11733)
#### Description
- Noz's `apply_filter` ("Open in Explorer") chips always opened the
Explorer in List view: the handler dropped the query's `requestType` and
never set `panelTypes`, so a grouped "count by service" landed on raw
rows.
- The chip now opens the view the query implies, on both the on-page
redirect and off-page navigation: `scalar` → Table, `time_series` → Time
Series, `trace` → Traces (traces signal only), anything else → List.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=292161688&issue=SigNoz%7Cnerve-pod%7C584
#### Screenshots / Screen Recordings
Prev - 


https://github.com/user-attachments/assets/a5f9f45c-6460-4109-a061-e2d49901d438



Now - 


https://github.com/user-attachments/assets/15c17c68-538a-43b9-965a-4440ec24a1ec



#### Additional Information
- Reviewable commit by commit: the fix, the `panelTypes` encoding
regression test, then the `trace` mapping from review.
- `distribution` isn't mapped: `query_range` rejects it, so the agent
can't send it.
- `trace` stays on List for logs and metrics, which have no Traces view.
- The existing `[apply_filter]` debug logs are left untouched.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
2026-11-03 17:10:21 +00:00
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
138 changed files with 4121 additions and 1313 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 }}

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:
@@ -4030,7 +4027,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -4067,10 +4066,11 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
type: integer
limitBy:
allOf:
@@ -4082,8 +4082,10 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. On raw requests the skip counts from where a cursor resumes,
and the skipped rows are still read, so prefer cursor for paging.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10105,6 +10107,10 @@ components:
description: The unique name of the formula, such as F1, used by selectedQueryName
and by panels. Referencing a formula from another formula is not reliable.
type: string
offset:
description: The number of ordered series or rows to skip before the page
starts.
type: integer
order:
description: The sort order of the formula result.
items:
@@ -10130,7 +10136,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10166,10 +10174,11 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
type: integer
limitBy:
allOf:
@@ -10181,8 +10190,10 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. On raw requests the skip counts from where a cursor resumes,
and the skipped rows are still read, so prefer cursor for paging.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10244,7 +10255,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10281,10 +10294,11 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
type: integer
limitBy:
allOf:
@@ -10296,8 +10310,10 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. On raw requests the skip counts from where a cursor resumes,
and the skipped rows are still read, so prefer cursor for paging.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10359,7 +10375,9 @@ components:
the query they draw.
cursor:
description: The opaque nextCursor value from a previous raw result, used
to fetch the next page.
to fetch the next page. Honored only by raw requests whose order starts
with timestamp, for logs followed by id in the same direction; every other
request ignores it.
type: string
disabled:
description: When true, the result is excluded from the response; the query
@@ -10396,10 +10414,11 @@ components:
are replaced by group-by label values.
type: string
limit:
description: The maximum number of rows for raw requests, or of series or
rows for grouped aggregations, keeping the top entries by order. When
0, aggregations are unlimited while raw requests ordered by timestamp
return a page of 10000 rows; follow nextCursor for the rest.
description: The maximum number of rows for raw and trace requests, or of
series or rows for grouped scalar and time_series requests, keeping the
top entries by order. When 0, scalar and time_series requests are unlimited,
raw requests ordered by timestamp return a page of 10000 rows to continue
with nextCursor, and other raw and trace requests return 100 rows.
type: integer
limitBy:
allOf:
@@ -10411,8 +10430,10 @@ components:
operators and selectedQueryName refer to it.
type: string
offset:
description: The number of rows to skip before returning results. Prefer
cursor for raw requests.
description: The number of rows for raw and trace requests, or of ordered
series or rows for scalar and time_series requests, to skip before the
page starts. On raw requests the skip counts from where a cursor resumes,
and the skipped rows are still read, so prefer cursor for paging.
type: integer
order:
description: The sort order of the result. Order by a group-by field, an
@@ -10513,8 +10534,8 @@ components:
description: The unique name of the trace operator query.
type: string
offset:
description: The number of raw results to skip before returning. Aggregation
requests ignore it.
description: The number of raw or trace rows, or of ordered series or rows
for time_series and scalar requests, to skip before the page starts.
type: integer
order:
description: The sort order of the matched traces; only span_count and trace_duration

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 {
@@ -5277,7 +5273,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
*/
cursor?: string;
/**
@@ -5310,7 +5306,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
*/
limit?: number;
/**
@@ -5324,7 +5320,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
*/
offset?: number;
/**
@@ -5669,7 +5665,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
*/
cursor?: string;
/**
@@ -5702,7 +5698,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
*/
limit?: number;
/**
@@ -5716,7 +5712,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
*/
offset?: number;
/**
@@ -5849,7 +5845,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
*/
cursor?: string;
/**
@@ -5882,7 +5878,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
*/
limit?: number;
/**
@@ -5896,7 +5892,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
*/
offset?: number;
/**
@@ -5948,7 +5944,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
*/
cursor?: string;
/**
@@ -5981,7 +5977,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
legend?: string;
/**
* @type integer
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
*/
limit?: number;
/**
@@ -5995,7 +5991,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
name?: string;
/**
* @type integer
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
*/
offset?: number;
/**
@@ -7068,6 +7064,11 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
*/
name?: string;
/**
* @type integer
* @description The number of ordered series or rows to skip before the page starts.
*/
offset?: number;
/**
* @type array,null
* @description The sort order of the formula result.
@@ -7150,7 +7151,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
name?: string;
/**
* @type integer
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts.
*/
offset?: number;
/**

View File

@@ -18,6 +18,7 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -29,14 +30,17 @@ function InputWithLabel({
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
/** Rewrites the typed value before it is shown and reported. */
sanitize?: (value: string) => string;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
onChange?.(e.target.value);
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
};
return (
@@ -78,6 +82,7 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

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

@@ -26,6 +26,7 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -278,7 +279,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', Number(value) || null);
handleChangeQueryData('limit', parseLimit(value));
},
[handleChangeQueryData],
);
@@ -374,6 +375,7 @@ function QueryAddOns({
label="Limit"
type="number"
onChange={handleChangeLimit}
sanitize={sanitizeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}

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

@@ -169,8 +169,15 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '10');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
await user.clear(input);
await user.type(input, '-5');
expect(input.value).toBe('5');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
});
it.each([

View File

@@ -12,10 +12,62 @@ import {
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
formatValueForExpression,
parseLimit,
removeKeysFromExpression,
sanitizeLimit,
} from '../utils';
describe('convertHavingToExpression', () => {
it.each([
['COUNT()', 'count() > 5'],
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
['P99(duration_nano)', 'p99(duration_nano) > 5'],
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
['count()', 'count() > 5'],
])('references %s by its lower-case function', (columnName, expected) => {
expect(
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
).toStrictEqual({ expression: expected });
});
it('joins several conditions and formats list values', () => {
expect(
convertHavingToExpression([
{ columnName: 'COUNT()', op: '>', value: 5 },
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
]),
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
});
});
describe('sanitizeLimit', () => {
it.each([
['10', '10'],
['-10', '10'],
['2.5', '25'],
['1e3', '13'],
['', ''],
])('sanitizes %j to %j', (value, expected) => {
expect(sanitizeLimit(value)).toBe(expected);
});
});
describe('parseLimit', () => {
it.each([
['10', 10],
['1', 1],
['', null],
['0', null],
['-5', null],
['2.5', null],
['abc', null],
])('parses %j as %j', (value, expected) => {
expect(parseLimit(value)).toBe(expected);
});
});
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -840,6 +840,16 @@ export const appendAndClause = (
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
};
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
export const sanitizeLimit = (value: string): string =>
value.replace(/\D/g, '');
/** A query or formula limit: a positive integer, otherwise none. */
export const parseLimit = (value: string): number | null => {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : null;
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value
@@ -871,7 +881,14 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
return `${columnName} ${op} ${formattedValue}`;
// The old having filter upper-cased the function (`COUNT()`); v5 only
// resolves the aggregation's own lower-case expression.
const reference = columnName.replace(
/^\s*([A-Za-z_]\w*)\(/,
(_, fn: string) => `${fn.toLowerCase()}(`,
);
return `${reference} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

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

@@ -26,6 +26,7 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -47,6 +48,10 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -333,6 +338,11 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
@@ -342,7 +352,9 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized);
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -353,8 +365,10 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
}
/** Picks the right rollback API call for a given action kind. */
@@ -613,6 +627,7 @@ export default function ActionsSection({
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -1,7 +1,16 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
@@ -28,6 +37,37 @@ function renderUndoChip(): HTMLElement {
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
@@ -81,3 +121,52 @@ describe('ActionsSection rollback chip', () => {
).resolves.toBeInTheDocument();
});
});
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
});
});

View File

@@ -0,0 +1,106 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -0,0 +1,30 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

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

@@ -2,6 +2,7 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { parseLimit, sanitizeLimit } from 'components/QueryBuilderV2/utils';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
@@ -171,7 +172,8 @@ export function Formula({
<InputWithLabel
label="Limit"
type="number"
onChange={(value): void => handleChangeLimit(Number(value))}
onChange={(value): void => handleChangeLimit(parseLimit(value))}
sanitize={sanitizeLimit}
initialValue={formula?.limit ?? undefined}
placeholder="Enter limit"
/>

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

@@ -475,12 +475,17 @@ export function QueryBuilderProvider({
const createNewBuilderQuery = useCallback(
(queries: IBuilderQuery[]): IBuilderQuery => {
const existNames = queries.map((item) => item.queryName);
// Hosts without a fixed data source (the panel editor) continue the
// previous query's signal rather than resetting to metrics.
const previous = queries[queries.length - 1];
const initialBuilderQuery =
initialQueryBuilderFormValuesMap[initialDataSource || DataSource.METRICS];
initialQueryBuilderFormValuesMap[
initialDataSource || previous?.dataSource || DataSource.METRICS
];
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
source: previous?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({

View File

@@ -0,0 +1,104 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery data source', () => {
it.each([DataSource.LOGS, DataSource.TRACES, DataSource.METRICS])(
'continues the previous %s query when the host fixes no data source',
(dataSource) => {
const { result } = renderQueryBuilder(initialQueriesMap[dataSource]);
act(() => {
result.current.addNewBuilderQuery();
});
const [, added] = result.current.currentQuery.builder.queryData;
expect(added.dataSource).toBe(dataSource);
expect(added.aggregations).toStrictEqual(
initialQueryBuilderFormValuesMap[dataSource].aggregations,
);
},
);
it('follows the last query when queries mix signals', () => {
const { result } = renderQueryBuilder(initialQueriesMap.logs);
act(() => {
result.current.addNewBuilderQuery();
});
act(() => {
result.current.handleSetQueryData(1, {
...initialQueryBuilderFormValuesMap.traces,
queryName: 'B',
expression: 'B',
});
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2].dataSource).toBe(
DataSource.TRACES,
);
});
it('takes the source from the last query, not the first', () => {
const [metricsQuery] = initialQueriesMap.metrics.builder.queryData;
const { result } = renderQueryBuilder({
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{ ...metricsQuery, source: '' },
{ ...metricsQuery, queryName: 'B', expression: 'B', source: 'meter' },
],
},
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2]).toMatchObject({
dataSource: DataSource.METRICS,
source: 'meter',
});
});
it('uses the host’s fixed data source over the previous query', () => {
const { result } = renderQueryBuilder(initialQueriesMap.metrics);
act(() => {
result.current.handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[1].dataSource).toBe(
DataSource.LOGS,
);
});
});

View File

@@ -1,6 +1,7 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
@@ -19,4 +20,14 @@ describe('buildExplorerNavigationUrl', () => {
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
initialQueriesMap[DataSource.LOGS],
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
});
});

View File

@@ -4,9 +4,13 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -22,12 +26,14 @@ import (
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
store llmpricingruletypes.Store
querier querier.Querier
telemetryMetadataStore telemetrytypes.MetadataStore
logger *slog.Logger
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -51,13 +57,47 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
return nil, err
}
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
return llmpricingruletypes.UnmappedModels(models, rules), nil
}
// Collect keeps the rule counts even when ClickHouse is unreachable: the
// counts that depend on it are skipped so the reporter retains their last value.
// Models come from the traces tag table rather than a span scan, so the
// unmapped count covers the metadata lookback instead of the last hour.
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
rules, err := module.listAllRules(ctx, orgID)
if err != nil {
return nil, err
}
stats := llmpricingruletypes.NewStatsFromRules(rules)
selectors := aiobservabilitytypes.StatsFieldValueSelectors(time.Now())
results := make([]*telemetrytypes.TelemetryFieldValues, len(selectors))
var lookups sync.WaitGroup
for i, selector := range selectors {
lookups.Go(func() {
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
if err != nil {
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
return
}
results[i] = fieldValues
})
}
lookups.Wait()
values := make(map[string]*telemetrytypes.TelemetryFieldValues, len(selectors))
for i, selector := range selectors {
if results[i] != nil {
values[selector.Name] = results[i]
}
}
return unmapped, nil
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
if models := values[aiobservabilitytypes.GenAIRequestModel]; models != nil {
maps.Copy(stats, llmpricingruletypes.NewStatsFromModels(models.StringValues, rules))
}
return stats, nil
}
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -13,6 +14,8 @@ type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
statsreporter.StatsCollector
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)

View File

@@ -182,7 +182,7 @@ func postProcessBuilderQuery[T any](
req *qbtypes.QueryRangeRequest,
) *qbtypes.Result {
result = q.applySeriesLimit(result, query.Limit, query.Order)
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
// Apply functions
if len(query.Functions) > 0 {
@@ -234,7 +234,7 @@ func postProcessMetricQuery(
reducesToScalar := req.RequestType == qbtypes.RequestTypeScalar && config.ReduceTo != qbtypes.ReduceToUnknown
if !reducesToScalar {
result = q.applySeriesLimit(result, query.Limit, query.Order)
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
}
if len(query.Functions) > 0 {
@@ -246,7 +246,7 @@ func postProcessMetricQuery(
// Apply reduce to for scalar request type
if reducesToScalar {
result = q.applyMetricReduceTo(result, config.ReduceTo)
result = q.applySeriesLimit(result, query.Limit, query.Order)
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
result.Value = convertTimeSeriesDataToScalar(tsData, tsData.QueryName)
}
@@ -263,7 +263,7 @@ func postProcessTraceOperator(
req *qbtypes.QueryRangeRequest,
) *qbtypes.Result {
result = q.applySeriesLimit(result, query.Limit, query.Order)
result = q.applySeriesLimit(result, query.Limit, query.Offset, query.Order)
// Apply functions if any
if len(query.Functions) > 0 {
@@ -296,7 +296,7 @@ func (q *querier) applyMetricReduceTo(result *qbtypes.Result, reduceOp qbtypes.R
}
// applySeriesLimit limits the number of series in the result.
func (q *querier) applySeriesLimit(result *qbtypes.Result, limit int, orderBy []qbtypes.OrderBy) *qbtypes.Result {
func (q *querier) applySeriesLimit(result *qbtypes.Result, limit, offset int, orderBy []qbtypes.OrderBy) *qbtypes.Result {
tsData, ok := result.Value.(*qbtypes.TimeSeriesData)
if !ok {
return result
@@ -305,7 +305,7 @@ func (q *querier) applySeriesLimit(result *qbtypes.Result, limit int, orderBy []
if tsData != nil {
for _, agg := range tsData.Aggregations {
// Use the ApplySeriesLimit function from querybuildertypes
agg.Series = qbtypes.ApplySeriesLimit(agg.Series, orderBy, limit)
agg.Series = qbtypes.ApplySeriesLimit(agg.Series, orderBy, limit, offset)
}
}
@@ -357,7 +357,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
case qbtypes.RequestTypeTimeSeries:
result := q.processTimeSeriesFormula(ctx, results, formula, req)
if result != nil {
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
result = q.applySeriesLimit(result, formula.Limit, formula.Offset, formula.Order)
results[name] = result
}
case qbtypes.RequestTypeHeatmap:
@@ -370,7 +370,7 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
bucketFormulaOutputAsHeatmap(tsData, bucketing)
resolveHeatmapBucketAxis(tsData, bucketing)
}
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
result = q.applySeriesLimit(result, formula.Limit, formula.Offset, formula.Order)
results[name] = result
}
case qbtypes.RequestTypeScalar:
@@ -651,7 +651,7 @@ func (q *querier) processScalarFormula(
}
// Apply ordering (and limit) before converting to scalar format.
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
// Convert back to scalar format
scalarResult := &qbtypes.ScalarData{

View File

@@ -186,6 +186,7 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
name string
reduceTo qbtypes.ReduceTo
order []qbtypes.OrderBy
offset int
expected [][]any
}{
{
@@ -211,6 +212,13 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
reduceTo: qbtypes.ReduceToMax,
expected: [][]any{{"payment", 40.0}, {"auth", 30.0}},
},
{
name: "Max_Desc_Offset1_SkipsHighestPeak",
reduceTo: qbtypes.ReduceToMax,
order: orderByValue(qbtypes.OrderDirectionDesc),
offset: 1,
expected: [][]any{{"auth", 30.0}, {"checkout", 10.0}},
},
}
for _, testCase := range testCases {
@@ -220,6 +228,7 @@ func TestPostProcessMetricQuery_LimitRanksByReducedValue(t *testing.T) {
Aggregations: []qbtypes.MetricAggregation{{MetricName: "http_requests_total", ReduceTo: testCase.reduceTo}},
Order: testCase.order,
Limit: 2,
Offset: testCase.offset,
}
req := &qbtypes.QueryRangeRequest{RequestType: qbtypes.RequestTypeScalar}

View File

@@ -0,0 +1,17 @@
package querybuilder
import "github.com/huandu/go-sqlbuilder"
// AddOffset skips offset rows. Call it after the ORDER BY and LIMIT clauses:
// the ClickHouse flavor renders OFFSET only inside a LIMIT, so an unlimited
// page writes the clause itself at the current position.
func AddOffset(sb *sqlbuilder.SelectBuilder, limit, offset int) {
if offset <= 0 {
return
}
if limit > 0 {
sb.Offset(offset)
return
}
sb.SQL("OFFSET " + sb.Var(offset))
}

View File

@@ -164,7 +164,7 @@ func NewModules(
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
Tag: tagModule,
}
}

Some files were not shown because too many files have changed in this diff Show More