Compare commits

..

41 Commits

Author SHA1 Message Date
Ashwin Bhatkal
dc60764059 test(e2e): drop classic form and v1 rule coverage from the alert suite 2026-11-03 18:58:28 +05:30
Ashwin Bhatkal
de75470c0d Merge branch 'nv/alerts-migration' into refactor/alerts-drop-v1 2026-11-03 18:52:20 +05:30
Naman Verma
602d959c6d Merge branch 'main' into nv/alerts-migration 2026-11-03 18:24:38 +05:30
Ashwin Bhatkal
6ab1ed87f4 refactor(alerts): move the query and chart preview cards to CSS modules 2026-11-03 18:20:09 +05:30
Ashwin Bhatkal
98a7c6f851 feat(alerts): remove the classic alert form 2026-11-03 11:56:53 +05:30
Ashwin Bhatkal
0c6c9fccf6 refactor(alerts): use the generated rule type enum for threshold and anomaly rules 2026-11-03 11:56:09 +05:30
Ashwin Bhatkal
16b057cded feat(alerts): stop sending version and source on alert rules 2026-11-03 11:55:52 +05:30
Naman Verma
f5e9786f91 Merge branch 'main' into nv/alerts-migration 2026-11-02 11:25:18 +05:30
Naman Verma
1b2a5e54f2 fix: make selected query mandatory 2026-10-29 13:00:32 +05:30
Naman Verma
94113eeed5 fix: make version field noop 2026-10-27 19:20:53 +05:30
Naman Verma
680f7de69e fix: empty query rules should migrate as disabled 2026-10-27 18:23:50 +05:30
Naman Verma
2237dbde9a fix: migrate rules with nil query as disabled 2026-10-27 18:06:38 +05:30
Naman Verma
aac321de58 chore: -ve eval window should migrate as disabled 2026-10-27 17:55:27 +05:30
Naman Verma
7a7c35eca0 chore: method reordering 2026-10-27 17:33:54 +05:30
Naman Verma
41d81cfcbc chore: method reordering 2026-10-27 17:29:43 +05:30
Naman Verma
00afd873e3 chore: split err checks 2026-10-27 17:28:51 +05:30
Naman Verma
428acb6f23 chore: clearer method name 2026-10-27 17:27:16 +05:30
Naman Verma
736ad2aba3 chore: no inline conversions in convertRule 2026-10-27 17:19:13 +05:30
Naman Verma
99f21adadd chore: simplify route validation 2026-10-27 17:11:32 +05:30
Naman Verma
bdfadbbe87 chore: remove unneeded check for schema version 2026-10-27 17:03:18 +05:30
Naman Verma
e23f96c7a1 chore: var renames 2026-10-27 16:59:14 +05:30
Naman Verma
3e1d76f3c6 chore: move type inline to its only usage 2026-10-27 16:55:08 +05:30
Naman Verma
5b91346a27 Merge branch 'main' into nv/alerts-migration 2026-10-27 16:26:05 +05:30
Naman Verma
7525667f28 fix: check for nil evaluation envelope 2026-10-27 15:25:39 +05:30
Naman Verma
1e4c96907d Merge branch 'main' into nv/alerts-migration 2026-10-27 12:24:29 +05:30
Naman Verma
55e76a182d Merge branch 'main' into nv/alerts-migration 2026-10-26 16:57:45 +05:30
Naman Verma
9135807532 chore: reduce diff 2026-10-26 11:22:55 +05:30
Naman Verma
a5f5dc4fb6 Merge branch 'main' into nv/alerts-migration 2026-10-26 11:02:32 +05:30
Naman Verma
872b4172d3 fix: migrate broken v1 rules as disabled rules 2026-10-26 05:44:15 +05:30
Naman Verma
87b1eed20a chore: remove extra v2 validations 2026-10-26 04:14:03 +05:30
Naman Verma
f6cfcdfc78 chore: more rule validations 2026-10-26 03:52:16 +05:30
Naman Verma
37cf5ae407 fix: fix all non-query related post-migration errors 2026-10-25 23:59:21 +05:30
Naman Verma
9a8f66be63 Merge branch 'main' into nv/alerts-migration 2026-10-25 22:06:04 +05:30
Naman Verma
3a5078e29b Merge branch 'main' into nv/alerts-migration 2026-10-22 02:05:57 +05:30
Naman Verma
7ebb1910b9 fix: remove nilnil return 2026-10-21 13:50:18 +05:30
Naman Verma
1e1d6f8d24 Merge branch 'main' into nv/alerts-migration 2026-10-21 13:39:57 +05:30
Naman Verma
6f6eea7187 fix: 400 on malformed JSON, missing alertType, unknown enums 2026-10-19 12:00:16 +05:30
Naman Verma
0bec6c5494 Merge branch 'main' into nv/alerts-migration 2026-10-18 09:41:38 +05:30
Naman Verma
d6744c1d8e chore: remove v1 rule apis 2026-10-16 15:20:38 +05:30
Naman Verma
6476be26bb Merge branch 'main' into nv/alerts-migration
# Conflicts:
#	pkg/query-service/app/http_handler.go
2026-10-16 14:56:00 +05:30
Naman Verma
06c54c6483 feat: migrate all rules to v2 schema 2026-10-16 11:17:10 +05:30
197 changed files with 2583 additions and 9207 deletions

View File

@@ -2,9 +2,6 @@ name: e2eci
on:
pull_request:
merge_group:
types:
- checks_requested
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -16,12 +13,8 @@ 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 }}
@@ -47,7 +40,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: lts/*
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -67,7 +60,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shard: [1, 2, 3, 4]
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
@@ -105,7 +98,7 @@ jobs:
- name: node
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: lts/*
- name: install-pnpm
uses: pnpm/action-setup@v6
with:
@@ -162,7 +155,7 @@ jobs:
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
- name: teardown-stack
if: always()
run: |

View File

@@ -2205,10 +2205,10 @@ components:
type: object
CloudintegrationtypesAWSConnectionArtifact:
properties:
cliCommand:
connectionUrl:
type: string
required:
- cliCommand
- connectionUrl
type: object
CloudintegrationtypesAWSIntegrationConfig:
properties:
@@ -2364,8 +2364,11 @@ components:
properties:
cliCommand:
type: string
cloudPowerShellCommand:
type: string
required:
- cliCommand
- cloudPowerShellCommand
type: object
CloudintegrationtypesAzureIntegrationConfig:
properties:
@@ -11573,20 +11576,10 @@ components:
description: Whether the rule is paused. A disabled rule is neither evaluated
nor notifies. Defaults to false.
type: boolean
evalWindow:
description: The lookback window as a Go duration such as 5m. Only read
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
instead.
type: string
evaluation:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
description: The evaluation window, as a kind and spec.
frequency:
description: How often the rule is evaluated, as a Go duration such as 1m.
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
it in evaluation.spec instead.
type: string
labels:
additionalProperties:
type: string
@@ -11598,13 +11591,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesNotificationSettings'
description: Grouping, re-notification and routing behaviour.
preferredChannels:
description: The displayName of each notification channel to notify, for
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
its own channels.
items:
type: string
type: array
ruleType:
allOf:
- $ref: '#/components/schemas/RuletypesRuleType'
@@ -11714,20 +11700,10 @@ components:
description: Whether the rule is paused. A disabled rule is neither evaluated
nor notifies. Defaults to false.
type: boolean
evalWindow:
description: The lookback window as a Go duration such as 5m. Only read
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
instead.
type: string
evaluation:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
description: The evaluation window, as a kind and spec.
frequency:
description: How often the rule is evaluated, as a Go duration such as 1m.
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
it in evaluation.spec instead.
type: string
id:
description: The unique identifier of the rule.
type: string
@@ -11742,13 +11718,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesNotificationSettings'
description: Grouping, re-notification and routing behaviour.
preferredChannels:
description: The displayName of each notification channel to notify, for
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
its own channels.
items:
type: string
type: array
ruleType:
allOf:
- $ref: '#/components/schemas/RuletypesRuleType'
@@ -11810,20 +11779,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesAlertCompositeQuery'
description: The queries whose result is compared against the thresholds.
matchType:
allOf:
- $ref: '#/components/schemas/RuletypesMatchType'
description: 'schemaVersion v1 only: which points in the window must satisfy
op. Accepted as at_least_once, all_the_times, on_average, in_total or
last, their aliases avg and sum, or the legacy codes as strings, ''1''
to ''5''. Required for v1; v2alpha1 sets matchType on each threshold.'
op:
allOf:
- $ref: '#/components/schemas/RuletypesCompareOperator'
description: 'schemaVersion v1 only: how the value is compared with target.
Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal
or outside_bounds, their symbols, or the legacy codes as strings, ''1''
to ''7''. Required for v1; v2alpha1 sets op on each threshold.'
requireMinPoints:
description: Whether a series is skipped until it has at least requiredNumPoints
points in the window. Defaults to false.
@@ -11841,17 +11796,6 @@ components:
description: The name of the query whose result is evaluated, such as A
or F1. Must match a query in compositeQuery.
type: string
target:
description: 'schemaVersion v1 only: the value compared against, in targetUnit.
Required for v1; v2alpha1 sets target on each threshold.'
format: double
nullable: true
type: number
targetUnit:
description: 'schemaVersion v1 only: the unit of target, such as percent
or ms. Converted to compositeQuery.unit before comparison when the two
differ.'
type: string
thresholds:
allOf:
- $ref: '#/components/schemas/RuletypesRuleThresholdData'
@@ -25339,56 +25283,6 @@ paths:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true
description: 'This endpoint lists all alert rules with their current evaluation
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
pagination.'
operationId: ListRules
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/RuletypesRule'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List alert rules
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint creates a new alert rule

View File

@@ -28,7 +28,7 @@ func ConvertConfig(src ExternalConfig) (InternalConfig, error)
// Over this:
type ConfigAdapter struct { ... }
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
func (adapter *ConfigAdapter) ToInternal() (InternalConfig, error)
func (a *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 (order *Order) AllLineItems() []LineItem { ... }
func (o *Order) AllLineItems() []LineItem { ... }
// Better: the validation logic operates on the full structure.
func ValidateOrder(order *Order) error { ... }
func ValidateOrder(o *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 (resolver sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
func (r 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 (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
func (h *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 (handler *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
}
// Call module functions
out, err := handler.module.CreateThing(req.Context(), claims.OrgID, &in)
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return

View File

@@ -59,8 +59,7 @@ Follow these rules:
### Unexported symbols
- 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).
- Struct receivers: one or two characters (`c`, `f`, `br`).
- 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 (sdk *SDK) Start(ctx context.Context) error {
<-sdk.startCh
func (i *SDK) Start(ctx context.Context) error {
<-i.startCh
return nil
}
func (sdk *SDK) Stop(ctx context.Context) error {
close(sdk.startCh)
func (i *SDK) Stop(ctx context.Context) error {
close(i.startCh)
return errors.Join(
sdk.sdk.Shutdown(ctx),
sdk.meterProviderShutdownFunc(ctx),
i.sdk.Shutdown(ctx),
i.meterProviderShutdownFunc(ctx),
)
}
```
@@ -197,8 +197,8 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
}
}
func (service *service) Start(ctx context.Context) error { ... }
func (service *service) Stop(ctx context.Context) error { ... }
func (s *service) Start(ctx context.Context) error { ... }
func (s *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 (maintenance *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
func (m *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 (entity *X) Validate() error { ... }
func (x *X) Validate() error { ... }
// Request-shape-only: checks that do not apply once the value is persisted.
func (postable *PostableX) Validate() error { ... }
func (p *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,6 +2,8 @@ package implcloudprovider
import (
"context"
"fmt"
"net/url"
"sort"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -16,14 +18,29 @@ 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) {
connectionArtifact, err := cloudintegrationtypes.NewAWSConnectionArtifact(account.ID, req.Config.AgentVersion, req.Credentials, req.Config.AWS.DeploymentRegion)
if err != nil {
return nil, err
}
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)
return &cloudintegrationtypes.ConnectionArtifact{
AWS: connectionArtifact,
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
}, nil
}

View File

@@ -34,8 +34,7 @@ type AnomalyRule struct {
provider anomaly.Provider
version string
logger *slog.Logger
logger *slog.Logger
seasonality anomaly.Seasonality
}
@@ -63,7 +62,6 @@ func NewAnomalyRule(
r := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -420,12 +418,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{

View File

@@ -3936,7 +3936,7 @@ export interface CloudintegrationtypesAWSConnectionArtifactDTO {
/**
* @type string
*/
cliCommand: string;
connectionUrl: string;
}
export interface CloudintegrationtypesAWSLogsCollectionStrategyDTO {
@@ -4146,6 +4146,10 @@ export interface CloudintegrationtypesAzureConnectionArtifactDTO {
* @type string
*/
cliCommand: string;
/**
* @type string
*/
cloudPowerShellCommand: string;
}
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {

View File

@@ -1,17 +0,0 @@
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

@@ -1,32 +0,0 @@
.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

@@ -1,75 +0,0 @@
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

@@ -1,74 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,71 +0,0 @@
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

@@ -1,33 +0,0 @@
import type { OverflowBadgesLayout } from './types';
/**
* How many badges fit `available` px, keeping room for the "+N" badge while any
* are left out. Always shows at least one, capped to truncate when too wide.
*/
export const computeOverflowBadgesLayout = (
available: number,
gap: number,
badgeWidths: number[],
overflowWidth: number,
): OverflowBadgesLayout => {
if (badgeWidths.length === 0) {
return { visibleCount: 0 };
}
let used = 0;
let visibleCount = 0;
for (let i = 0; i < badgeWidths.length; i++) {
const next = used + (i > 0 ? gap : 0) + badgeWidths[i];
const reserve = i < badgeWidths.length - 1 ? gap + overflowWidth : 0;
if (next + reserve > available) {
break;
}
used = next;
visibleCount++;
}
if (visibleCount > 0) {
return { visibleCount };
}
const reserve = badgeWidths.length > 1 ? gap + overflowWidth : 0;
return { visibleCount: 1, firstMaxWidth: Math.max(available - reserve, 0) };
};

View File

@@ -25,8 +25,6 @@ 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(
@@ -251,7 +249,11 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
describe('query actions menu', () => {
const openQueryActions = async (): Promise<void> => {
await userEvent.click(screen.getAllByTestId('query-actions')[0]);
const trigger = document.querySelector(
'.query-actions-container [aria-haspopup="menu"]',
) as HTMLElement;
expect(trigger).toBeInTheDocument();
await userEvent.click(trigger);
};
it('Clone calls cloneQuery with the query', async () => {
@@ -259,7 +261,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
await openQueryActions();
await userEvent.click(
await screen.findByTestId('query-actions-item-clone-query'),
await screen.findByRole('menuitem', { name: 'Clone' }),
);
expect(baseQBContext.cloneQuery).toHaveBeenCalledTimes(1);
@@ -273,44 +275,41 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await screen.findByTestId('query-actions-item-clone-query');
await screen.findByRole('menuitem', { name: 'Clone' });
expect(
screen.queryByTestId('query-actions-item-delete-query'),
screen.queryByRole('menuitem', { name: 'Delete' }),
).not.toBeInTheDocument();
});
it('shows Delete with more than one query and deletes on click', async () => {
const [queryA] = (baseQBContext.currentQuery as Query).builder.queryData;
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 handleDeleteQuery = jest.fn();
mockedUseQueryOperations.mockReturnValue({
...mockedUseQueryOperations({} as Parameters<UseQueryOperations>[0]),
handleDeleteQuery,
});
render(
<QueryBuilderV2Provider>
<QueryV2
index={0}
query={queryA}
version="v4"
isAvailableToDisable={false}
isMultiQueryAllowed
queriesCount={2}
onSignalSourceChange={jest.fn()}
signalSourceChangeEnabled={false}
savePreviousQuery={false}
/>
</QueryBuilderV2Provider>,
);
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
await openQueryActions();
await userEvent.click(
await screen.findByTestId('query-actions-item-delete-query'),
await screen.findByRole('menuitem', { name: 'Delete' }),
);
expect(handleDeleteQuery).toHaveBeenCalledTimes(1);
}, 15000);
});
});
it('fx button is disabled when functions already exist', () => {

View File

@@ -4,7 +4,6 @@ import type {
TouchEvent as ReactTouchEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import {
ArrowDown,
ArrowUp,
@@ -32,7 +31,7 @@ type TanStackHeaderRowProps<TData = unknown> = {
onRemoveColumn?: (columnId: string) => void;
orderBy?: SortState | null;
onSort?: (sort: SortState | null) => void;
/** The fill column and any after it cannot be resized */
/** Last column cannot be resized */
isLastColumn?: boolean;
};
@@ -90,19 +89,6 @@ 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,11 +53,7 @@ import { useFlatItems } from './useFlatItems';
import { useResetScroll } from './useResetScroll';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import {
buildPageSizeItems,
buildTanstackColumnDef,
getFillColumnIndex,
} from './utils';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -432,9 +428,6 @@ function TanStackTableInner<TData, TItemKey = string>(
);
const tableHeader = useCallback(() => {
const fillColumnIndex = getFillColumnIndex(
flatHeaders.map((header) => columnsById.get(header.id)),
);
return (
<DndContext
sensors={sensors}
@@ -460,7 +453,7 @@ function TanStackTableInner<TData, TItemKey = string>(
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index >= fillColumnIndex}
isLastColumn={index === flatHeaders.length - 1}
/>
);
})}

View File

@@ -1,7 +1,7 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle, getFillColumnIndex } from './utils';
import { getColumnWidthStyle } from './utils';
export function VirtuosoTableColGroup<TData>({
columns,
@@ -13,9 +13,6 @@ 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>
@@ -25,7 +22,7 @@ export function VirtuosoTableColGroup<TData>({
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === fillColumnIndex;
const isLastColumn = index === visibleTanstackColumns.length - 1;
return (
<col
key={tanstackCol.id}

View File

@@ -1,51 +0,0 @@
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

@@ -1,28 +0,0 @@
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,8 +74,6 @@ 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,19 +63,6 @@ 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

@@ -53,7 +53,6 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',

View File

@@ -26,7 +26,6 @@ export const REACT_QUERY_KEY = {
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',

View File

@@ -1,136 +0,0 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
matchType: '3',
op: '1',
target: 100,
targetUnit: 'rpm',
});
let mockWindowOpen: jest.Mock;
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function findLinkForAlertType(
links: HTMLElement[],
alertType: AlertTypes,
): HTMLElement {
const link = links.find(
(el) =>
el.closest('[data-testid]')?.getAttribute('data-testid') ===
`alert-type-card-${alertType}`,
);
expect(link).toBeTruthy();
return link as HTMLElement;
}
function clickLinkAndVerifyRedirect(
link: HTMLElement,
expectedUrl: string,
): void {
fireEvent.click(link);
expect(mockWindowOpen).toHaveBeenCalledWith(expectedUrl, '_blank');
}
describe('Alert rule documentation redirection', () => {
let renderResult: ReturnType<typeof render>;
beforeAll(() => {
mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
});
beforeEach(() => {
act(() => {
renderResult = render(
<CreateAlertPage />,
{},
{
initialRoute: ROUTES.ALERTS_NEW,
},
);
});
});
it('should render alert type cards', () => {
const { getByText, getAllByText } = renderResult;
// Check for the heading
expect(getByText('choose_alert_type')).toBeInTheDocument();
// Check for alert type titles and descriptions
Object.values(AlertTypes).forEach((alertType) => {
const title = ALERT_TYPE_TO_TITLE[alertType];
expect(getByText(title)).toBeInTheDocument();
expect(getByText(`${title}_desc`)).toBeInTheDocument();
});
const clickHereLinks = getAllByText(
'Click here to see how to create a sample alert.',
);
expect(clickHereLinks).toHaveLength(5);
});
it('should redirect to correct documentation for each alert type', () => {
const { getAllByText } = renderResult;
const clickHereLinks = getAllByText(
'Click here to see how to create a sample alert.',
);
const alertTypeCount = Object.keys(AlertTypes).length;
expect(clickHereLinks).toHaveLength(alertTypeCount);
Object.values(AlertTypes).forEach((alertType) => {
const linkForAlertType = findLinkForAlertType(clickHereLinks, alertType);
const expectedUrl = ALERT_TYPE_URL_MAP[alertType];
clickLinkAndVerifyRedirect(linkForAlertType, expectedUrl.selection);
});
expect(mockWindowOpen).toHaveBeenCalledTimes(alertTypeCount);
});
});
describe('Create alert page redirection', () => {
Object.values(AlertTypes)
.filter((type) => type !== AlertTypes.ANOMALY_BASED_ALERT)
.forEach((alertType) => {
it(`should redirect to create alert page for ${alertType} and "Check an example alert" should redirect to the correct documentation`, () => {
const { getByRole } = render(
<CreateAlertPage />,
{},
{
initialRoute: `${ROUTES.ALERTS_NEW}?alertType=${alertType}&showClassicCreateAlertsPage=true`,
},
);
act(() => {
fireEvent.click(
getByRole('button', {
name: /alert setup guide/i,
}),
);
});
expect(mockWindowOpen).toHaveBeenCalledWith(
ALERT_TYPE_URL_MAP[alertType].creation,
'_blank',
);
});
});
});

View File

@@ -1,60 +0,0 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
.mockReturnValue({
matchType: '3',
op: '1',
target: 100,
targetUnit: 'rpm',
});
describe('Anomaly Alert Documentation Redirection', () => {
let mockWindowOpen: jest.Mock;
beforeAll(() => {
mockWindowOpen = jest.fn();
window.open = mockWindowOpen;
});
it('should handle anomaly alert documentation redirection correctly', () => {
// The setup guide button only exists in the classic form.
const { getByRole } = render(<CreateAlertPage />, undefined, {
initialRoute: `${ROUTES.ALERTS_NEW}?ruleType=anomaly_rule&showClassicCreateAlertsPage=true`,
});
const alertType = AlertTypes.ANOMALY_BASED_ALERT;
act(() => {
fireEvent.click(
getByRole('button', {
name: /alert setup guide/i,
}),
);
});
expect(mockWindowOpen).toHaveBeenCalledWith(
ALERT_TYPE_URL_MAP[alertType].creation,
'_blank',
);
});
});

View File

@@ -23,25 +23,6 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
},
}));
jest.mock('container/FormAlertRules', () => ({
__esModule: true,
default: function MockFormAlertRules({
alertType,
}: {
alertType: AlertTypes;
}): JSX.Element {
return (
<div>
<h1>Form Alert Rules</h1>
<p>{alertType}</p>
</div>
);
},
AlertDetectionTypes: {
THRESHOLD_ALERT: 'threshold_rule',
ANOMALY_DETECTION_ALERT: 'anomaly_rule',
},
}));
jest.mock('container/CreateAlertV2', () => ({
__esModule: true,
default: function MockCreateAlertV2({
@@ -76,7 +57,6 @@ const mockGetUrlQuery = jest.fn();
const mockSafeNavigate = jest.fn();
const mockDeleteUrlQuery = jest.fn();
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
describe('CreateAlertRule', () => {
@@ -95,20 +75,6 @@ describe('CreateAlertRule', () => {
useAppContextSpy.mockReturnValue(getAppContextMockState());
});
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
if (key === QueryParams.alertType) {
return AlertTypes.METRICS_BASED_ALERT;
}
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render new flow when alertType is provided', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.alertType) {
@@ -124,7 +90,6 @@ describe('CreateAlertRule', () => {
mockGetUrlQuery.mockReturnValue(null);
useCompositeQueryParamSpy.mockReturnValue(null);
render(<CreateAlertRule />);
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
@@ -149,9 +114,6 @@ describe('CreateAlertRule', () => {
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
}
if (key === QueryParams.ruleType) {
return 'anomaly_rule';
}
@@ -160,7 +122,6 @@ describe('CreateAlertRule', () => {
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {
@@ -284,27 +245,5 @@ describe('CreateAlertRule', () => {
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'true';
}
return null;
});
render(<CreateAlertRule />);
fireEvent.click(screen.getByText('metric_based_alert'));
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.ruleType,
'threshold_rule',
);
expect(mockSetUrlQuery).toHaveBeenCalledWith(
QueryParams.alertType,
AlertTypes.METRICS_BASED_ALERT,
);
expect(mockSafeNavigate).toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,10 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
AlertDef,
@@ -63,7 +63,7 @@ export const alertDefaults: AlertDef = {
export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
condition: {
compositeQuery: {
builderQueries: {
@@ -201,14 +201,6 @@ export const exceptionAlertDefaults: AlertDef = {
alert: '',
};
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
[AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
};
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',

View File

@@ -1,14 +1,13 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Form, Tabs, TabsProps } from 'antd';
import { Tabs, TabsProps } from 'antd';
import logEvent from 'api/common/logEvent';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import CreateAlertV2 from 'container/CreateAlertV2';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -16,25 +15,20 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import SelectAlertType from './SelectAlertType';
import './CreateAlertRule.styles.scss';
function CreateRules(): JSX.Element {
const [formInstance] = Form.useForm();
const compositeQuery = useGetCompositeQueryParam();
const queryParams = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
const version = queryParams.get(QueryParams.version);
const showClassicCreateAlertsPageFlag =
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
const isTypeSelectionMode =
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
@@ -46,7 +40,7 @@ function CreateRules(): JSX.Element {
}, [isTypeSelectionMode]);
const alertType = useMemo(() => {
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
if (!alertTypeFromURL) {
@@ -59,14 +53,6 @@ function CreateRules(): JSX.Element {
return alertTypeFromURL as AlertTypes;
}, [alertTypeFromURL, ruleTypeFromURL, compositeQuery?.builder.queryData]);
const initialAlertValue: AlertDef = useMemo(
() => ({
...ALERTS_VALUES_MAP[alertType],
version: version || ENTITY_VERSION_V5,
}),
[alertType, version],
);
const handleTabChange = useCallback(
(tab: string): void => {
queryParams.set('tab', tab);
@@ -80,13 +66,10 @@ function CreateRules(): JSX.Element {
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
} else {
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
queryParams.set(QueryParams.alertType, type);
}
@@ -99,26 +82,8 @@ function CreateRules(): JSX.Element {
if (isTypeSelectionMode) {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (showClassicCreateAlertsPageFlag) {
return (
<FormAlertRules
alertType={alertType}
formInstance={formInstance}
initialValue={initialAlertValue}
ruleId=""
/>
);
}
return <CreateAlertV2 alertType={alertType} />;
}, [
isTypeSelectionMode,
handleSelectType,
showClassicCreateAlertsPageFlag,
alertType,
formInstance,
initialAlertValue,
]);
}, [isTypeSelectionMode, handleSelectType, alertType]);
const items: TabsProps['items'] = [
{

View File

@@ -1,14 +1,7 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
[groupByLabels],
);
const handleSwitchToClassicExperience = useCallback(() => {
logEvent('Alert: Switch to classic experience button clicked', {});
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
safeNavigate(url, { replace: true });
}, [safeNavigate, urlQuery]);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
<Button
prefix={<RotateCcw size={12} />}
onClick={handleSwitchToClassicExperience}
variant="solid"
color="secondary"
size="sm"
>
Switch to Classic Experience
</Button>
</div>
)}
<div className="alert-header__content">

View File

@@ -1,9 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { mockAppLocation } from 'tests/router-mocks';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -11,11 +8,6 @@ import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
@@ -101,37 +93,4 @@ describe('CreateAlertHeader', () => {
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
).toHaveValue('TEST_ALERT');
});
it('should navigate to classic experience when button is clicked', () => {
renderCreateAlertHeader();
const switchToClassicExperienceButton = screen.getByText(
'Switch to Classic Experience',
);
expect(switchToClassicExperienceButton).toBeInTheDocument();
fireEvent.click(switchToClassicExperienceButton);
const params = new URLSearchParams();
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
expect(mockSafeNavigate).toHaveBeenCalledWith(
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
{ replace: true },
);
});
it('should not render "switch to classic experience" button when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(
screen.queryByText('Switch to Classic Experience'),
).not.toBeInTheDocument();
});
});

View File

@@ -556,8 +556,6 @@ describe('Footer utils', () => {
},
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
source: 'http://localhost/',
version: 'v5',
});
});

View File

@@ -1,6 +1,6 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
@@ -266,7 +266,7 @@ export function buildCreateThresholdAlertRulePayload(
// Evaluation
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
let ruleType: string = RuletypesRuleTypeDTO.threshold_rule;
if (query.queryType === EQueryType.PROM) {
ruleType = 'promql_rule';
}
@@ -292,9 +292,7 @@ export function buildCreateThresholdAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
@@ -321,7 +319,7 @@ export function buildCreateAnomalyAlertRulePayload(
return {
...payload,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
alertType: AlertTypes.METRICS_BASED_ALERT,
condition: {
...payload.condition,

View File

@@ -179,7 +179,6 @@ function QuerySection(): JSX.Element {
isAnomalyDetection={isAnomalyDetection}
key={currentQuery.queryType}
ruleId=""
hideTitle
/>
</div>
);

View File

@@ -3,12 +3,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
initialClickHouseData,
initialQueryPromQLData,
} from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import store from 'store';
import { TestRouter } from 'tests/router';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -237,7 +237,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
},
undefined,
true,
@@ -252,7 +252,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
},
undefined,
true,
@@ -376,7 +376,7 @@ describe('QuerySection', () => {
expect(result[1]).toStrictEqual({
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
});
});

View File

@@ -92,30 +92,17 @@
.chart-preview-container {
margin-right: 4px;
.alert-chart-container {
.ant-card {
border: 1px solid var(--l1-border);
.ant-card-body {
background-color: var(--l1-background);
.chart-preview-headline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
.chart-preview-headline {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
.y-axis-unit-selector-component {
margin-top: 0;
}
}
.y-axis-unit-selector-component {
margin-top: 0;
}
}
}
}
.alert-query-section-container {
margin: 0;
background-color: var(--l1-background);
border: 1px solid var(--l1-border);
}
}

View File

@@ -1,4 +1,4 @@
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
@@ -18,8 +18,8 @@ export function buildAlertDefForChartPreview({
...initialAlertDef,
ruleType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
condition: {
...initialAlertDef.condition,
targetUnit: thresholdState.thresholds?.[0].unit,

View File

@@ -1,4 +1,3 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
@@ -39,7 +38,6 @@ const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
condition: {
compositeQuery: {

View File

@@ -10,6 +10,7 @@ import {
useRef,
useState,
} from 'react';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { useAppLocation } from 'lib/router/useAppLocation';
import {
useCreateRule,
@@ -17,7 +18,6 @@ import {
useUpdateRuleByID,
} from 'api/generated/services/rules';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -186,8 +186,8 @@ export function CreateAlertProvider(
[QueryParams.alertType]: value,
[QueryParams.ruleType]:
value === AlertTypes.ANOMALY_BASED_ALERT
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
},
undefined,
true,

View File

@@ -1,9 +1,9 @@
import { createPortal } from 'react-dom';
import { Color } from '@signozhq/design-tokens';
import { Spin } from 'antd';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -251,8 +251,7 @@ export {
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
const isAnomalyRule = alertDef.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
@@ -287,7 +286,7 @@ export function getThresholdStateFromAlertDef(
export function getAlertTypeFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): AlertTypes {
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
if (alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
return alertDef?.alertType as AlertTypes;

View File

@@ -1,50 +0,0 @@
import { Form } from 'antd';
import EditAlertV2 from 'container/EditAlertV2';
import FormAlertRules from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
function EditRules({
initialValue,
ruleId,
initialV2AlertValue,
}: EditRulesProps): JSX.Element {
const [formInstance] = Form.useForm();
if (
initialV2AlertValue !== null &&
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
) {
return (
<EditAlertV2
initialAlert={initialV2AlertValue}
alertType={initialValue.alertType as AlertTypes}
/>
);
}
return (
<FormAlertRules
alertType={
initialValue.alertType
? (initialValue.alertType as AlertTypes)
: AlertTypes.METRICS_BASED_ALERT
}
formInstance={formInstance}
initialValue={initialValue}
ruleId={ruleId}
/>
);
}
interface EditRulesProps {
initialValue: AlertDef;
ruleId: string;
initialV2AlertValue: PostableAlertRuleV2 | null;
}
export default EditRules;

View File

@@ -1,268 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Button, Flex, Form, Select, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import ROUTES from 'constants/routes';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef, Labels } from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
import LabelSelect from './labels';
import {
FormContainer,
FormItemMedium,
InputSmall,
SeveritySelect,
StepHeading,
TextareaMedium,
} from './styles';
import './FormAlertRules.styles.scss';
const { Option } = Select;
interface BasicInfoProps {
isNewRule: boolean;
alertDef: AlertDef;
setAlertDef: (a: AlertDef) => void;
}
function BasicInfo({
isNewRule,
alertDef,
setAlertDef,
}: BasicInfoProps): JSX.Element {
const { t } = useTranslation('alerts');
const {
isLoading,
data: channels,
error,
isError,
refetch,
} = useChannelOptions();
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
useState(false);
useEffect(() => {
const hasPreferredChannels =
(alertDef.preferredChannels && alertDef.preferredChannels.length > 0) ||
isNewRule;
setShouldBroadCastToAllChannels(!hasPreferredChannels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleBroadcastToAllChannels = (shouldBroadcast: boolean): void => {
setShouldBroadCastToAllChannels(shouldBroadcast);
setAlertDef({
...alertDef,
broadcastToAll: shouldBroadcast,
});
};
const noChannels = channels.length === 0;
const handleCreateNewChannels = useCallback(() => {
logEvent('Alert: Create notification channel button clicked', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);
useEffect(() => {
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
logEvent('Alert: New alert creation page visited', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
numberOfChannels: channels.length,
});
hasLoggedEvent.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);
const refetchChannels = async (): Promise<void> => {
await refetch();
};
return (
<>
<StepHeading> {t('alert_form_step4')} </StepHeading>
<FormContainer>
<Form.Item
label={t('field_severity')}
labelAlign="left"
name={['labels', 'severity']}
>
<SeveritySelect
getPopupContainer={popupContainer}
defaultValue="critical"
data-testid="alert-severity-select"
onChange={(value: unknown | string): void => {
const s = (value as string) || 'critical';
setAlertDef({
...alertDef,
labels: {
...alertDef.labels,
severity: s,
},
});
}}
>
<Option value="critical">{t('option_critical')}</Option>
<Option value="error">{t('option_error')}</Option>
<Option value="warning">{t('option_warning')}</Option>
<Option value="info">{t('option_info')}</Option>
</SeveritySelect>
</Form.Item>
<Form.Item
required
name="alert"
labelAlign="left"
label={t('field_alert_name')}
rules={[
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
]}
>
<InputSmall
data-testid="alert-name-input-v1"
onChange={(e): void => {
setAlertDef({
...alertDef,
alert: e.target.value,
});
}}
/>
</Form.Item>
<Form.Item
label={t('field_alert_desc')}
labelAlign="left"
name={['annotations', 'description']}
>
<TextareaMedium
data-testid="alert-description-input"
onChange={(e): void => {
setAlertDef({
...alertDef,
annotations: {
...alertDef.annotations,
description: e.target.value,
},
});
}}
/>
</Form.Item>
<FormItemMedium label={t('field_labels')}>
<LabelSelect
onSetLabels={(l: Labels): void => {
setAlertDef({
...alertDef,
labels: {
...l,
},
});
}}
initialValues={alertDef.labels}
/>
</FormItemMedium>
<FormItemMedium
name="alert_all_configured_channels"
label="Alert all the configured channels"
>
<Switch
color="primary"
textPlacement="right"
disabledTooltip={
isLoading
? 'Wait for the channels to load'
: 'No channels. Ask an admin to create a notification channel'
}
value={shouldBroadCastToAllChannels}
onChange={handleBroadcastToAllChannels}
disabled={noChannels || !!isLoading}
testId="alert-broadcast-to-all-channels"
/>
</FormItemMedium>
{!shouldBroadCastToAllChannels && (
<Tooltip
title={
noChannels && !addNewChannelPermission
? 'No channels. Ask an admin to create a notification channel'
: undefined
}
placement="right"
>
<FormItemMedium
label="Notification Channels"
name="notification_channels"
required
rules={[
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
]}
>
<ChannelSelect
onDropdownOpen={refetchChannels}
disabled={shouldBroadCastToAllChannels}
currentValue={alertDef.preferredChannels}
handleCreateNewChannels={handleCreateNewChannels}
channels={channels}
isLoading={isLoading}
hasError={isError}
error={error as APIError}
onSelectChannels={(preferredChannels): void => {
setAlertDef({
...alertDef,
preferredChannels,
});
}}
/>
</FormItemMedium>
</Tooltip>
)}
{noChannels && (
<Tooltip
title={
!addNewChannelPermission
? 'Ask an admin to create a notification channel'
: undefined
}
placement="right"
>
<Button
onClick={handleCreateNewChannels}
className="create-notification-btn"
disabled={!addNewChannelPermission}
>
<Flex align="center" justify="center">
<Plus size="md" />
Create a notification channel
</Flex>
</Button>
</Tooltip>
)}
</FormContainer>
</>
);
}
export default BasicInfo;

View File

@@ -1,116 +0,0 @@
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Select, Spin } from 'antd';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import { useNotifications } from 'hooks/useNotifications';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import APIError from 'types/api/error';
import { StyledCreateChannelOption, StyledSelect } from './styles';
export interface ChannelSelectProps {
disabled?: boolean;
currentValue?: string[];
onSelectChannels: (s: string[]) => void;
onDropdownOpen: () => void;
isLoading: boolean;
channels: ChannelOption[];
hasError: boolean;
error: APIError;
handleCreateNewChannels: () => void;
}
function ChannelSelect({
disabled,
currentValue,
onSelectChannels,
onDropdownOpen,
channels,
isLoading,
hasError,
error,
handleCreateNewChannels,
}: ChannelSelectProps): JSX.Element | null {
// init namespace for translations
const { t } = useTranslation('alerts');
const { notifications } = useNotifications();
const handleChange = (value: string[]): void => {
if (value.includes('add-new-channel')) {
handleCreateNewChannels();
return;
}
onSelectChannels(value);
};
if (hasError) {
notifications.error({
message: error?.getErrorCode?.() || 'Error',
description: error?.getErrorMessage?.() || 'Something went wrong',
});
}
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const renderOptions = (): ReactNode[] => {
const children: ReactNode[] = [];
if (!isLoading && addNewChannelPermission) {
children.push(
<Select.Option key="add-new-channel" value="add-new-channel">
<StyledCreateChannelOption>
<Plus size="md" />
Create a new channel
</StyledCreateChannelOption>
</Select.Option>,
);
}
if (isLoading || channels.length === 0) {
return children;
}
channels.forEach((o) => {
children.push(
<Select.Option key={o.id} value={o.name}>
{o.name}
</Select.Option>,
);
});
return children;
};
return (
<StyledSelect
disabled={disabled}
status={hasError ? 'error' : ''}
mode="multiple"
style={{ width: '100%' }}
placeholder={t('placeholder_channel_select')}
data-testid="alert-channel-select"
value={currentValue}
notFoundContent={isLoading && <Spin size="small" />}
onDropdownVisibleChange={(open): void => {
if (open) {
onDropdownOpen();
}
}}
onChange={(value): void => {
handleChange(value as string[]);
}}
optionLabelProp="label"
>
{renderOptions()}
</StyledSelect>
);
}
ChannelSelect.defaultProps = {
disabled: false,
currentValue: [],
};
export default ChannelSelect;

View File

@@ -1,13 +0,0 @@
import { Select } from 'antd';
import styled from 'styled-components';
export const StyledSelect = styled(Select)`
border-radius: 4px;
`;
export const StyledCreateChannelOption = styled.div`
color: var(--accent-primary);
display: flex;
align-items: center;
gap: 8px;
`;

View File

@@ -0,0 +1,25 @@
.chartCard {
position: relative;
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
background-color: var(--card);
}
.chartCardBody {
height: 57vh;
padding: var(--spacing-12) 0;
border-radius: 0 0 var(--radius-1) var(--radius-1);
background-color: var(--l1-background);
:global(.plot-tag) {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
width: fit-content;
margin-left: var(--spacing-3);
padding: 0 var(--spacing-2) 0 var(--spacing-3);
border-radius: var(--radius-2);
background: var(--l3-background);
backdrop-filter: blur(6px);
}
}

View File

@@ -12,10 +12,6 @@
height: calc(100% - 24px);
}
.ant-card-body {
padding: 12px;
}
.anomaly-alert-evaluation-view-loading-container {
height: 100%;
display: flex;

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { useAppLocation } from 'lib/router/useAppLocation';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
@@ -45,11 +46,10 @@ import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import { getTimeRange } from 'utils/getTimeRange';
import { AlertDetectionTypes } from '..';
import ChartContent from './ChartContent';
import { ChartContainer } from './styles';
import { getThresholds } from './utils';
import styles from './ChartPreview.module.scss';
import './ChartPreview.styles.scss';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -279,7 +279,7 @@ function ChartPreview({
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
const isAnomalyDetectionAlert =
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
const chartDataAvailable =
chartData &&
@@ -300,57 +300,59 @@ function ChartPreview({
return (
<div className="alert-chart-container" ref={graphRef}>
<ChartContainer>
<div className="chart-preview-header">
{headline}
{isWarning && (
<WarningPopover warningData={queryResponse.data?.warning as Warning} />
)}
</div>
<div className={styles.chartCard}>
<div className={styles.chartCardBody}>
<div className="chart-preview-header">
{headline}
{isWarning && (
<WarningPopover warningData={queryResponse.data?.warning as Warning} />
)}
</div>
<div className="threshold-alert-uplot-chart-container">
{queryResponse.isLoading && (
<Spinner size="large" tip="Loading..." height="100%" />
)}
{(queryResponse?.isError || queryResponse?.error) && !isCancelled && (
<ErrorInPlace error={queryResponse.error as APIError} />
)}
<div className="threshold-alert-uplot-chart-container">
{queryResponse.isLoading && (
<Spinner size="large" tip="Loading..." height="100%" />
)}
{(queryResponse?.isError || queryResponse?.error) && !isCancelled && (
<ErrorInPlace error={queryResponse.error as APIError} />
)}
{isCancelled && !queryResponse.isLoading && !hasResultData && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load the chart preview.' />
)}
{isCancelled && !queryResponse.isLoading && !hasResultData && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load the chart preview.' />
)}
{chartDataAvailable && !isAnomalyDetectionAlert && (
<ChartContent
panelType={graphType}
alertId={alertDef?.id}
query={query || currentQuery}
apiResponse={queryResponse.data?.payload}
data={chartData}
thresholds={resolvedThresholds}
yAxisUnit={yAxisUnit}
legendPosition={legendPosition}
isDarkMode={isDarkMode}
timezone={timezone}
width={chartWidth}
height={chartHeight}
minTimeScale={minTimeScale}
maxTimeScale={maxTimeScale}
onDragSelect={onDragSelect}
/>
)}
{chartDataAvailable &&
isAnomalyDetectionAlert &&
isAnomalyDetectionEnabled &&
queryResponse?.data?.payload?.data?.resultType === 'time_series' && (
<AnomalyAlertEvaluationView
data={queryResponse?.data?.payload}
{chartDataAvailable && !isAnomalyDetectionAlert && (
<ChartContent
panelType={graphType}
alertId={alertDef?.id}
query={query || currentQuery}
apiResponse={queryResponse.data?.payload}
data={chartData}
thresholds={resolvedThresholds}
yAxisUnit={yAxisUnit}
legendPosition={legendPosition}
isDarkMode={isDarkMode}
timezone={timezone}
width={chartWidth}
height={chartHeight}
minTimeScale={minTimeScale}
maxTimeScale={maxTimeScale}
onDragSelect={onDragSelect}
/>
)}
{chartDataAvailable &&
isAnomalyDetectionAlert &&
isAnomalyDetectionEnabled &&
queryResponse?.data?.payload?.data?.resultType === 'time_series' && (
<AnomalyAlertEvaluationView
data={queryResponse?.data?.payload}
yAxisUnit={yAxisUnit}
/>
)}
</div>
</div>
</ChartContainer>
</div>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { red } from '@ant-design/colors';
import { Card, Tooltip } from 'antd';
import styled from 'styled-components';
export const NotFoundContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
min-height: 55vh;
`;
export const FailedMessageContainer = styled(Tooltip)`
position: absolute;
color: ${red};
top: 4rem;
left: 10px;
`;
export const ChartContainer = styled(Card)`
border-radius: 4px;
&&& {
position: relative;
}
.plot-tag {
margin-left: 6px;
display: inline-flex;
padding: 0px 4px 0px 6px;
align-items: center;
gap: 6px;
border-radius: 4px;
background: var(--l3-background);
backdrop-filter: blur(6px);
width: fit-content;
}
.ant-card-body {
padding: 1.5rem 0;
height: 57vh;
/* padding-bottom: 2rem; */
}
`;

View File

@@ -1,146 +0,0 @@
.create-alert-modal {
.ant-modal-content {
background-color: var(--l3-background);
.ant-modal-confirm-title {
color: var(--l1-foreground);
}
.ant-modal-confirm-content {
.ant-typography {
color: var(--l1-foreground);
}
}
.ant-modal-confirm-btns {
button:nth-of-type(1) {
background-color: var(--l1-border);
border: none;
color: var(--l1-foreground);
}
}
}
}
.form-alert-rules-container {
&.create-mode {
padding: 16px;
}
}
.triggered-alerts-container {
padding: 8px;
}
.alert-rules-list-container {
padding: 8px;
}
.planned-downtime-container {
padding: 8px;
}
.steps-container {
width: 80%;
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
}
}
.qb-chart-preview-container {
margin-bottom: 1rem;
display: flex;
flex-direction: row;
gap: 1rem;
}
.overview-header {
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
.alert-type-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
.alert-type-title {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.ant-typography {
margin: 0;
}
}
}
.chart-preview-container {
position: relative;
display: flex;
flex-direction: row;
gap: 1rem;
.ant-card {
flex: 1;
}
}
.detection-method-container {
margin: 24px 0;
.ant-tabs-nav {
margin-bottom: 0;
.ant-tabs-tab {
padding: 12px 0;
}
}
.detection-method-description {
padding: 8px 0;
font-size: 12px;
}
}
.info-help-btns {
display: grid;
grid-template-columns: auto auto;
gap: 12px;
margin-top: 20px;
.doc-redirection-btn {
color: var(--bg-aqua-500) !important;
border-color: var(--bg-aqua-500) !important;
}
.facing-issue-btn {
width: 100% !important;
}
}
.create-notification-btn {
box-shadow: none;
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
padding: var(--spacing-6);
background-color: var(--l1-background);
:global(.alert-tabs) {
padding: 0;
}
:global(.alert-tabs .ant-tabs .ant-tabs-nav) {
padding: var(--spacing-4) 0;
}
:global(.alert-tabs .ant-tabs-nav-wrap),
:global(.alert-tabs .ant-tabs-extra-content) {
padding: 0;
}
}

View File

@@ -51,27 +51,3 @@
padding: 0 !important;
}
}
.alert-query-section-container {
.ant-card-body {
padding: 0px;
.alert-tabs {
padding: 0px;
.ant-tabs {
.ant-tabs-nav {
padding: 8px 0;
.ant-tabs-nav-wrap {
padding: 0px;
}
}
.ant-tabs-extra-content {
padding: 0px;
}
}
}
}
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Color } from '@signozhq/design-tokens';
import { Button, Tabs, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
@@ -21,8 +20,8 @@ import { EQueryType } from 'types/common/dashboard';
import ChQuerySection from './ChQuerySection';
import PromqlSection from './PromqlSection';
import { FormContainer, StepHeading } from './styles';
import styles from './QuerySection.module.scss';
import './QuerySection.styles.scss';
const ANOMALY_QUERY_SUPPORT_CLICKHOUSE_ISSUE =
@@ -41,11 +40,8 @@ function QuerySection({
alertDef,
panelType,
ruleId,
hideTitle,
isAnomalyDetection,
}: QuerySectionProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const [currentTab, setCurrentTab] = useState(queryCategory);
const [signalSource, setSignalSource] = useState<string>('metrics');
@@ -290,18 +286,11 @@ function QuerySection({
return null;
};
const step2Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '2' : '1';
return (
<>
{!hideTitle && (
<StepHeading> {t('alert_form_step2', { step: step2Label })}</StepHeading>
)}
<FormContainer className="alert-query-section-container">
<div>{renderTabs(alertType)}</div>
{renderQuerySection(currentTab)}
</FormContainer>
</>
<div className={styles.container}>
<div>{renderTabs(alertType)}</div>
{renderQuerySection(currentTab)}
</div>
);
}
@@ -315,12 +304,10 @@ interface QuerySectionProps {
alertDef: AlertDef;
panelType: PANEL_TYPES;
ruleId: string;
hideTitle?: boolean;
isAnomalyDetection?: boolean;
}
QuerySection.defaultProps = {
hideTitle: false,
isAnomalyDetection: false,
};

View File

@@ -1,10 +0,0 @@
.rule-definition {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.rule-unit-selector {
width: 150px;
}

View File

@@ -1,525 +0,0 @@
import { useTranslation } from 'react-i18next';
import {
Checkbox,
Collapse,
Form,
InputNumber,
InputNumberProps,
Select,
SelectProps,
Space,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
} from 'container/CreateAlertV2/AlertCondition/utils';
import {
AlertDef,
defaultAlgorithm,
defaultCompareOp,
defaultEvalWindow,
defaultFrequency,
defaultMatchType,
defaultSeasonality,
} from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard';
import { popupContainer } from 'utils/selectPopupContainer';
import { AlertDetectionTypes } from '.';
import {
FormContainer,
InlineSelect,
StepHeading,
VerticalLine,
} from './styles';
import './RuleOptions.styles.scss';
function RuleOptions({
alertDef,
setAlertDef,
queryCategory,
queryOptions,
yAxisUnit,
}: RuleOptionsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const { ruleType } = alertDef;
const handleMatchOptChange = (value: string | unknown): void => {
const m = (value as string) || alertDef.condition?.matchType;
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
matchType: m,
},
});
};
const onChangeSelectedQueryName = (value: string | unknown): void => {
if (typeof value !== 'string') {
return;
}
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
selectedQueryName: value,
},
});
};
const renderCompareOps = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultCompareOp}
value={alertDef.condition?.op}
style={{ minWidth: '120px' }}
data-testid="alert-threshold-op-select"
onChange={(value: string | unknown): void => {
const newOp = (value as string) || '';
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
op: newOp,
},
});
}}
>
<Select.Option value="1">{t('option_above')}</Select.Option>
<Select.Option value="2">{t('option_below')}</Select.Option>
{/* hide equal and not eqaul in case of analmoy based alert */}
{ruleType !== 'anomaly_rule' && (
<>
<Select.Option value="3">{t('option_equal')}</Select.Option>
<Select.Option value="4">{t('option_notequal')}</Select.Option>
</>
)}
{/* the value 5 and 6 are reserved for above or equal and below or equal */}
{ruleType === 'anomaly_rule' && (
<Select.Option value="7">{t('option_above_below')}</Select.Option>
)}
</InlineSelect>
);
const renderMatchOpts = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultMatchType}
style={{ minWidth: '130px' }}
value={alertDef.condition?.matchType}
data-testid="alert-threshold-match-type-select-v1"
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
>
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
<Select.Option value="2">{t('option_allthetimes')}</Select.Option>
{ruleType !== 'anomaly_rule' && (
<>
<Select.Option value="3">{t('option_onaverage')}</Select.Option>
<Select.Option value="4">{t('option_intotal')}</Select.Option>
<Select.Option value="5">{t('option_last')}</Select.Option>
</>
)}
</InlineSelect>
);
const onChangeEvalWindow = (value: string | unknown): void => {
const ew = (value as string) || alertDef.evalWindow;
setAlertDef({
...alertDef,
evalWindow: ew,
});
};
const onChangeAlgorithm = (value: string | unknown): void => {
const alg = (value as string) || alertDef.condition.algorithm;
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
algorithm: alg,
},
});
};
const onChangeSeasonality = (value: string | unknown): void => {
const seasonality = (value as string) || alertDef.condition.seasonality;
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
seasonality,
},
});
};
const onChangeDeviation = (value: number): void => {
const target = value || alertDef.condition.target || 3;
setAlertDef({
...alertDef,
condition: { ...alertDef.condition, target: Number(target) },
});
};
const renderEvalWindows = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultEvalWindow}
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
<Select.Option value="4h0m0s">{t('option_4hours')}</Select.Option>
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
</InlineSelect>
);
const renderPromEvalWindows = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultEvalWindow}
style={{ minWidth: '120px' }}
value={alertDef.evalWindow}
onChange={onChangeEvalWindow}
data-testid="alert-eval-window-select"
>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
</InlineSelect>
);
const renderAlgorithms = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultAlgorithm}
style={{ minWidth: '120px' }}
value={alertDef.condition.algorithm}
onChange={onChangeAlgorithm}
>
<Select.Option value="standard">Standard</Select.Option>
</InlineSelect>
);
const renderDeviationOpts = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={3}
style={{ minWidth: '120px' }}
value={alertDef.condition.target}
onChange={(value: number | unknown): void => {
if (typeof value === 'number') {
onChangeDeviation(value);
}
}}
>
<Select.Option value={1}>1</Select.Option>
<Select.Option value={2}>2</Select.Option>
<Select.Option value={3}>3</Select.Option>
<Select.Option value={4}>4</Select.Option>
<Select.Option value={5}>5</Select.Option>
<Select.Option value={6}>6</Select.Option>
<Select.Option value={7}>7</Select.Option>
</InlineSelect>
);
const renderSeasonality = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultSeasonality}
style={{ minWidth: '120px' }}
value={alertDef.condition.seasonality}
onChange={onChangeSeasonality}
>
<Select.Option value="hourly">Hourly</Select.Option>
<Select.Option value="daily">Daily</Select.Option>
<Select.Option value="weekly">Weekly</Select.Option>
</InlineSelect>
);
const renderThresholdRuleOpts = (): JSX.Element => (
<Form.Item>
<Typography.Text>
{t('text_condition1')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
<Typography.Text>is</Typography.Text>
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}{' '}
{t('text_condition3')} {renderEvalWindows()}
</Typography.Text>
</Form.Item>
);
const renderPromRuleOptions = (): JSX.Element => (
<Form.Item>
<Typography.Text>
{t('text_condition1')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
<Typography.Text>is</Typography.Text>
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}
{t('text_condition3')} {renderPromEvalWindows()}
</Typography.Text>
</Form.Item>
);
const onChange: InputNumberProps['onChange'] = (value): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
op: alertDef.condition?.op || defaultCompareOp,
matchType: alertDef.condition?.matchType || defaultMatchType,
target: Number(value) || 0,
},
});
};
const onChangeAlertUnit: SelectProps['onChange'] = (value) => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
targetUnit: value as string,
},
});
};
const onChangeFrequency = (value: string | unknown): void => {
const freq = (value as string) || alertDef.frequency;
setAlertDef({
...alertDef,
frequency: freq,
});
};
const renderAnomalyRuleOpts = (): JSX.Element => (
<Form.Item>
<Typography.Text className="rule-definition">
{t('text_condition1_anomaly')}
<InlineSelect
getPopupContainer={popupContainer}
allowClear
showSearch
options={queryOptions}
placeholder={t('selected_query_placeholder')}
value={alertDef.condition.selectedQueryName}
onChange={onChangeSelectedQueryName}
/>
{t('text_condition3')} {renderEvalWindows()}
<Typography.Text>is</Typography.Text>
{renderDeviationOpts()}
<Typography.Text>deviations</Typography.Text>
{renderCompareOps()}
<Typography.Text>the predicted data</Typography.Text>
{renderMatchOpts()}
using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '}
seasonality
</Typography.Text>
</Form.Item>
);
const renderFrequency = (): JSX.Element => (
<InlineSelect
getPopupContainer={popupContainer}
defaultValue={defaultFrequency}
style={{ minWidth: '120px' }}
value={alertDef.frequency}
onChange={onChangeFrequency}
>
<Select.Option value="1m0s">{t('option_1min')}</Select.Option>
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
<Select.Option value="30m0s">{t('option_30min')}</Select.Option>
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
<Select.Option value="3h0m0s">{t('option_3hours')}</Select.Option>
<Select.Option value="6h0m0s">{t('option_6hours')}</Select.Option>
<Select.Option value="12h0m0s">{t('option_12hours')}</Select.Option>
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
</InlineSelect>
);
const selectedCategory = getCategoryByOptionId(yAxisUnit);
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
return (
<>
<StepHeading>{t('alert_form_step3', { step: step3Label })}</StepHeading>
<FormContainer>
{queryCategory === EQueryType.PROM && renderPromRuleOptions()}
{queryCategory !== EQueryType.PROM &&
ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<>{renderAnomalyRuleOpts()}</>
)}
{queryCategory !== EQueryType.PROM &&
ruleType === AlertDetectionTypes.THRESHOLD_ALERT &&
renderThresholdRuleOpts()}
<Space direction="vertical" size="large">
{ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
<Space direction="horizontal" align="center">
<Form.Item noStyle>
<InputNumber
addonBefore={t('field_threshold')}
value={alertDef?.condition?.target}
onChange={onChange}
type="number"
data-testid="alert-threshold-target-input"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Form.Item noStyle>
<Select
className="rule-unit-selector"
getPopupContainer={popupContainer}
allowClear
showSearch
options={categorySelectOptions}
placeholder={t('field_unit')}
value={alertDef.condition.targetUnit}
onChange={onChangeAlertUnit}
/>
</Form.Item>
</Space>
)}
<Collapse>
<Collapse.Panel header={t('More options')} key="1">
<Space direction="vertical" size="large">
<VerticalLine>
<Space direction="horizontal" align="center">
<Typography.Text>{t('text_alert_frequency')}</Typography.Text>
{renderFrequency()}
</Space>
</VerticalLine>
<VerticalLine>
<Space direction="horizontal" align="center">
<Form.Item noStyle name={['condition', 'alertOnAbsent']}>
<Checkbox
checked={alertDef?.condition?.alertOnAbsent}
onChange={(e): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
alertOnAbsent: e.target.checked,
},
});
}}
/>
</Form.Item>
<Typography.Text>{t('text_alert_on_absent')}</Typography.Text>
<Form.Item noStyle name={['condition', 'absentFor']}>
<InputNumber
min={1}
value={alertDef?.condition?.absentFor}
onChange={(value): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
absentFor: Number(value) || 0,
},
});
}}
type="number"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Typography.Text>{t('text_for')}</Typography.Text>
</Space>
</VerticalLine>
<VerticalLine>
<Space direction="horizontal" align="center">
<Form.Item noStyle name={['condition', 'requireMinPoints']}>
<Checkbox
checked={alertDef?.condition?.requireMinPoints}
onChange={(e): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
requireMinPoints: e.target.checked,
},
});
}}
/>
</Form.Item>
<Typography.Text>{t('text_require_min_points')}</Typography.Text>
<Form.Item noStyle name={['condition', 'requiredNumPoints']}>
<InputNumber
min={1}
value={alertDef?.condition?.requiredNumPoints}
onChange={(value): void => {
setAlertDef({
...alertDef,
condition: {
...alertDef.condition,
requiredNumPoints: Number(value) || 0,
},
});
}}
type="number"
onWheel={(e): void => e.currentTarget.blur()}
/>
</Form.Item>
<Typography.Text>{t('text_num_points')}</Typography.Text>
</Space>
</VerticalLine>
</Space>
</Collapse.Panel>
</Collapse>
</Space>
</FormContainer>
</>
);
}
interface RuleOptionsProps {
alertDef: AlertDef;
setAlertDef: (a: AlertDef) => void;
queryCategory: EQueryType;
queryOptions: DefaultOptionType[];
yAxisUnit: string;
}
export default RuleOptions;

View File

@@ -1,125 +0,0 @@
import { renderHook } from '@testing-library/react';
import { ReduceOperators } from 'types/common/queryBuilder';
import { usePrefillAlertConditions } from '../usePrefillAlertConditions';
const TEST_MAPPINGS = {
op: {
'>': '1',
'<': '2',
'=': '3',
},
matchType: {
avg: '3',
sum: '4',
},
};
jest.mock('lib/router/useAppLocation', () => {
const mockThreshold1 = {
index: '0d11f426-a02e-48da-867c-b79c6ef1ff06',
isEditEnabled: false,
keyIndex: 1,
selectedGraph: 'graph',
thresholdColor: 'Orange',
thresholdFormat: 'Text',
thresholdLabel: 'Caution',
thresholdOperator: '>',
thresholdTableOptions: 'A',
thresholdUnit: 'rpm',
thresholdValue: 800,
};
const mockThreshold2 = {
index: 'edbe8ef2-fa54-4cb9-b343-7afe883bb714',
isEditEnabled: false,
keyIndex: 0,
selectedGraph: 'graph',
thresholdColor: 'Red',
thresholdFormat: 'Text',
thresholdLabel: 'Danger',
thresholdOperator: '<',
thresholdTableOptions: 'A',
thresholdUnit: 'rpm',
thresholdValue: 900,
};
return {
useAppLocation: jest.fn().mockReturnValue({
state: {
thresholds: [mockThreshold1, mockThreshold2],
},
}),
};
});
const mockStagedQuery = {
builder: {
queryData: [
{
reduceTo: ReduceOperators.AVG,
},
],
},
};
describe('usePrefillAlertConditions', () => {
it('returns the correct matchType for a single query', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions(mockStagedQuery as any),
);
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
});
it('returns null matchType for a single query with unsupported time aggregation', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions({
builder: { queryData: [{ reduceTo: 'p90' }] },
} as any),
);
expect(result.current.matchType).toBeNull();
});
it('returns the correct matchType for multiple queries with same time aggregation', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions({
builder: {
queryData: [
{
reduceTo: ReduceOperators.AVG,
},
{
reduceTo: ReduceOperators.AVG,
},
],
},
} as any),
);
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
});
it('returns null matchType for multiple queries with different time aggregation', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions({
builder: {
queryData: [
{
reduceTo: ReduceOperators.AVG,
},
{
reduceTo: ReduceOperators.SUM,
},
],
},
} as any),
);
expect(result.current.matchType).toBeNull();
});
it('returns the correct op, target, targetUnit from the higher priority threshold for multiple thresholds', () => {
const { result } = renderHook(() =>
usePrefillAlertConditions(mockStagedQuery as any),
);
expect(result.current.op).toBe(TEST_MAPPINGS.op['<']);
expect(result.current.target).toBe(900);
expect(result.current.targetUnit).toBe('rpm');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,210 +0,0 @@
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CircleAlert, CircleX } from '@signozhq/icons';
import { Button, Input, message, Modal } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { map } from 'lodash-es';
import { Labels } from 'types/api/alerts/def';
import { v4 as uuid } from 'uuid';
import { Badge } from '@signozhq/ui/badge';
import { Pill } from '@signozhq/ui/pill';
import { QueryChipContainer, QueryChipItem, SearchContainer } from './styles';
import { ILabelRecord } from './types';
import { createQuery, flattenLabels, prepareLabels } from './utils';
type LabelStep = 'Idle' | 'LabelKey' | 'LabelValue';
type LabelEvent = 'NEXT' | 'onBlur' | 'RESET';
interface LabelSelectProps {
onSetLabels: (q: Labels) => void;
initialValues: Labels | undefined;
}
function LabelSelect({
onSetLabels,
initialValues,
}: LabelSelectProps): JSX.Element | null {
const { t } = useTranslation('alerts');
const isDarkMode = useIsDarkMode();
const [currentVal, setCurrentVal] = useState('');
const [staging, setStaging] = useState<string[]>([]);
const [queries, setQueries] = useState<ILabelRecord[]>(
initialValues ? flattenLabels(initialValues) : [],
);
const [step, setStep] = useState<LabelStep>('Idle');
const dispatchChanges = (updatedRecs: ILabelRecord[]): void => {
onSetLabels(prepareLabels(updatedRecs, initialValues));
setQueries(updatedRecs);
};
const onSelectLabelValue = (): void => {
if (currentVal !== '') {
setStaging((prevState) => [...prevState, currentVal]);
} else {
return;
}
setCurrentVal('');
};
const onValidateQuery = (): void => {
if (currentVal === '') {
return;
}
const generatedQuery = createQuery([...staging, currentVal]);
if (generatedQuery) {
dispatchChanges([...queries, generatedQuery]);
setStaging([]);
setCurrentVal('');
setStep('Idle');
}
};
const send = (event: LabelEvent): void => {
if (event === 'RESET') {
setStep('Idle');
return;
}
if (event === 'NEXT') {
if (step === 'Idle') {
setStep('LabelKey');
} else if (step === 'LabelKey') {
onSelectLabelValue();
setStep('LabelValue');
} else if (step === 'LabelValue') {
onValidateQuery();
}
return;
}
if (event === 'onBlur') {
if (step === 'LabelKey') {
onSelectLabelValue();
setStep('LabelValue');
} else if (step === 'LabelValue') {
onValidateQuery();
}
}
};
const handleFocus = (): void => {
if (step === 'Idle') {
send('NEXT');
}
};
const handleBlur = useCallback((): void => {
if (staging.length === 1 && staging[0] !== undefined) {
send('onBlur');
}
}, [staging]);
useEffect(() => {
handleBlur();
}, [handleBlur]);
const handleLabelChange = (event: ChangeEvent<HTMLInputElement>): void => {
// Remove the colon if it's the last character.
// As the colon is used to separate the key and value in the query.
setCurrentVal(
event.target?.value.endsWith(':')
? event.target?.value.slice(0, -1)
: event.target?.value,
);
};
const handleClose = (key: string): void => {
dispatchChanges(queries.filter((queryData) => queryData.key !== key));
};
const handleClearAll = (): void => {
Modal.confirm({
title: 'Confirm',
icon: <CircleAlert size="md" />,
content: t('remove_label_confirm'),
onOk() {
send('RESET');
dispatchChanges([]);
setStaging([]);
message.success(t('remove_label_success'));
},
okText: t('button_yes'),
cancelText: t('button_no'),
});
};
const renderPlaceholder = useCallback((): string => {
if (step === 'LabelKey') {
return 'Enter a label key then press ENTER.';
}
if (step === 'LabelValue') {
return `Enter a value for label key(${staging[0]}) then press ENTER.`;
}
return t('placeholder_label_key_pair');
}, [t, step, staging]);
return (
<SearchContainer isDarkMode={isDarkMode} disabled={false}>
<div style={{ display: 'inline-flex', flexWrap: 'wrap' }}>
{queries.length > 0 &&
map(queries, (query): JSX.Element => {
const isClosable =
query.key !== 'severity' && query.key !== 'description';
return (
<QueryChipContainer key={query.key}>
{isClosable ? (
<Pill.Closeable
closeAriaLabel={`Remove ${query.key}`}
onClose={(): void => handleClose(query.key)}
>
{query.key}: {query.value}
</Pill.Closeable>
) : (
<Badge textTransform="none" variant="solid" color="secondary">
{query.key}: {query.value}
</Badge>
)}
</QueryChipContainer>
);
})}
</div>
<div>
{map(staging, (item) => (
<QueryChipItem key={uuid()}>{item}</QueryChipItem>
))}
</div>
<div style={{ display: 'flex', width: '100%' }}>
<Input
data-testid="alert-labels-input-v1"
placeholder={renderPlaceholder()}
onChange={handleLabelChange}
onKeyUp={(e): void => {
if (e.key === 'Enter' || e.code === 'Enter' || e.key === ':') {
send('NEXT');
}
if (step === 'Idle') {
send('NEXT');
}
}}
bordered={false}
value={currentVal as never}
style={{ flex: 1 }}
onFocus={handleFocus}
onBlur={handleBlur}
/>
{queries.length > 0 || staging.length > 0 || currentVal ? (
<Button
onClick={handleClearAll}
icon={<CircleX size="md" />}
type="text"
/>
) : null}
</div>
</SearchContainer>
);
}
export default LabelSelect;

View File

@@ -1,41 +0,0 @@
import { grey } from '@ant-design/colors';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
interface SearchContainerProps {
isDarkMode: boolean;
disabled: boolean;
}
export const SearchContainer = styled.div<SearchContainerProps>`
border-radius: 4px;
background: ${({ isDarkMode }): string => (isDarkMode ? '#000' : '#fff')};
flex: 1;
display: flex;
flex-direction: column;
padding: 0.2rem;
border: 1px solid #ccc5;
${({ disabled }): string => (disabled ? `cursor: not-allowed;` : '')}
`;
export const QueryChipContainer = styled.span`
display: flex;
align-items: center;
margin-right: 0.5rem;
&:hover {
& > * {
background: ${grey.primary}44;
}
}
[data-slot='badge'] {
margin-right: 0.1rem;
}
`;
export const QueryChipItem = styled(Badge).attrs({
color: 'secondary' as const,
variant: 'solid' as const,
textTransform: 'none' as const,
})`
margin-right: 0.1rem;
`;

View File

@@ -1,9 +0,0 @@
export interface ILabelRecord {
key: string;
value: string;
}
export interface IOption {
label: string;
value: string;
}

View File

@@ -1,54 +0,0 @@
import { Labels } from 'types/api/alerts/def';
import { ILabelRecord } from './types';
const hiddenLabels = ['severity', 'description'];
export const createQuery = (
selectedItems: Array<string | string[]> = [],
): ILabelRecord | null => {
if (selectedItems.length === 2) {
return {
key: selectedItems[0] as string,
value: selectedItems[1] as string,
};
}
return null;
};
export const flattenLabels = (labels: Labels): ILabelRecord[] => {
const recs: ILabelRecord[] = [];
Object.keys(labels).forEach((key) => {
if (!hiddenLabels.includes(key)) {
recs.push({
key,
value: labels[key] || '',
});
}
});
return recs;
};
export const prepareLabels = (
recs: ILabelRecord[],
alertLabels: Labels | undefined,
): Labels => {
const labels: Labels = {};
recs.forEach((rec) => {
if (!hiddenLabels.includes(rec.key)) {
labels[rec.key] = rec.value;
}
});
if (alertLabels) {
Object.keys(alertLabels).forEach((key) => {
if (hiddenLabels.includes(key)) {
labels[key] = alertLabels[key];
}
});
}
return labels;
};

View File

@@ -1,103 +0,0 @@
import { Button, Card, Col, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled from 'styled-components';
const { TextArea } = Input;
const { Item } = Form;
export const StyledLeftContainer = styled(Col)`
&&& {
margin-right: 1rem;
}
`;
export const MainFormContainer = styled(Form)``;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 1rem;
margin-bottom: 3rem;
}
`;
export const ActionButton = styled(Button)`
margin-right: 1rem;
`;
export const QueryButton = styled(Button)`
&&& {
display: flex;
align-items: center;
margin-right: 1rem;
}
`;
export const QueryContainer = styled(Card)`
&&& {
margin-top: 1rem;
min-height: 23.5%;
}
`;
export const Container = styled.div`
margin-top: 1rem;
display: flex;
flex-direction: column;
`;
export const StepHeading = styled.p`
margin-top: 1rem;
font-weight: bold;
`;
export const InlineSelect = styled(Select)`
display: inline-block;
width: 10% !important;
margin-left: 0.3em;
margin-right: 0.3em;
`;
export const SeveritySelect = styled(Select)`
width: 25% !important;
`;
export const VerticalLine = styled.div`
border-left: 2px solid #e8e8e8; /* Adjust color and thickness as desired */
padding-left: 20px; /* Adjust spacing to content as needed */
margin-left: 20px; /* Adjust margin as desired */
height: 100%; /* Adjust based on your layout needs */
`;
export const InputSmall = styled(Input)`
width: 40% !important;
`;
export const FormContainer = styled(Card)`
margin-top: 1rem;
display: flex;
flex-direction: column;
border-radius: 4px;
.ant-card-body {
padding: 12px;
}
`;
export const TextareaMedium = styled(TextArea)`
width: 70%;
`;
export const FormItemMedium = styled(Item)`
width: 70%;
`;
export const ChannelSelectTip = styled(Typography.Text)`
color: hsla(0, 0%, 100%, 0.3);
`;
export const StepContainer = styled.div`
margin-top: 2rem;
`;

View File

@@ -1,91 +0,0 @@
import { useMemo } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { ThresholdProps } from 'types/api/widgets/threshold';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const THRESHOLD_COLORS_SORTING_ORDER = ['Red', 'Orange', 'Green', 'Blue'];
export const usePrefillAlertConditions = (
stagedQuery: Query | null,
): {
matchType: string | null;
op: string | null;
target: number | undefined;
targetUnit: string | undefined;
} => {
const location = useAppLocation();
// Extract and set match type
const reduceTo = useMemo(() => {
if (!stagedQuery) {
return null;
}
const isSameTimeAggregation = stagedQuery.builder.queryData.every(
(queryData) =>
queryData?.reduceTo === stagedQuery.builder.queryData[0]?.reduceTo,
);
return isSameTimeAggregation
? stagedQuery.builder.queryData[0]?.reduceTo
: null;
}, [stagedQuery]);
const matchType = useMemo(() => {
switch (reduceTo) {
case 'avg':
return '3';
case 'sum':
return '4';
default:
return null;
}
}, [reduceTo]);
// Extract and set threshold operator, value and unit
const threshold = useMemo(() => {
const { thresholds } = (location.state as {
thresholds: ThresholdProps[];
}) || {
thresholds: null,
};
if (!thresholds || thresholds.length === 0) {
return null;
}
const sortedThresholds = thresholds.sort((a, b) => {
const aIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
a.thresholdColor || '',
);
const bIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
b.thresholdColor || '',
);
return aIndex - bIndex;
});
return sortedThresholds[0];
}, [location.state]);
const thresholdOperator = useMemo(() => {
const op = threshold?.thresholdOperator;
switch (op) {
case '>':
case '>=':
return '1';
case '<':
case '<=':
return '2';
case '=':
return '3';
default:
return null;
}
}, [threshold]);
const thresholdUnit = useMemo(() => threshold?.thresholdUnit, [threshold]);
const thresholdValue = useMemo(() => threshold?.thresholdValue, [threshold]);
return {
matchType,
op: thresholdOperator,
target: thresholdValue,
targetUnit: thresholdUnit,
};
};

View File

@@ -1,14 +0,0 @@
// Write a test for getUpdatedStepInterval function in src/container/FormAlertRules/utils.ts
import { getUpdatedStepInterval } from './utils';
describe('getUpdatedStepInterval', () => {
it('should return 60', () => {
const result = getUpdatedStepInterval('5m0s');
expect(result).toBe(60);
});
it('should return 60 for 10m0s', () => {
const result = getUpdatedStepInterval('10m0s');
expect(result).toBe(60);
});
});

View File

@@ -1,7 +1,4 @@
import { SelectProps } from 'antd';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import getStep from 'lib/getStep';
import {
IBuilderFormula,
IBuilderQuery,
@@ -10,48 +7,6 @@ import {
IPromQLQuery,
} from 'types/api/queryBuilder/queryBuilderData';
// toChartInterval converts eval window to chart selection time interval
export const toChartInterval = (evalWindow: string | undefined): Time => {
switch (evalWindow) {
case '1m0s':
return '1m';
case '5m0s':
return '5m';
case '10m0s':
return '10m';
case '15m0s':
return '15m';
case '30m0s':
return '30m';
case '1h0m0s':
return '1h';
case '3h0m0s':
return '3h';
case '4h0m0s':
return '4h';
case '6h0m0s':
return '6h';
case '12h0m0s':
return '12h';
case '24h0m0s':
return '1d';
default:
return '5m';
}
};
export const getUpdatedStepInterval = (evalWindow?: string): number => {
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: toChartInterval(evalWindow),
});
return getStep({
start,
end,
inputFormat: 'ns',
});
};
export const getSelectedQueryOptions = (
queries:
| Array<

View File

@@ -15,7 +15,14 @@ function AlertMessage({
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
<Spin indicator={<LoaderCircle size={14} className="animate-spin" />} />
<Spin
indicator={
<LoaderCircle
size={14}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
/>
Waiting for connection, retrying in{' '}
<span className="retry-time">10</span> secs...
</div>

View File

@@ -12,13 +12,6 @@
overflow: hidden;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
&__content {
flex: 1;
overflow-y: auto;
@@ -151,6 +144,52 @@
}
}
.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,24 +24,22 @@ function CloudAccountSetupModal({
activeView,
selectedRegions,
includeAllRegions,
isGeneratingCommand,
connectionCommand,
isGeneratingUrl,
setSelectedRegions,
setIncludeAllRegions,
handleRegionSelect,
handleSubmit,
handleClose,
setActiveView,
accountId,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
checkConnectionAgain,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
{ shouldConfirm: Boolean(connectionCommand), onClose: handleClose },
);
const renderContent = useCallback(() => {
return (
<div className="cloud-account-setup-modal__content">
@@ -52,12 +50,15 @@ function CloudAccountSetupModal({
includeAllRegions={includeAllRegions}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
connectionCommand={connectionCommand}
accountId={accountId}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
onConnectionSuccess={handleConnectionSuccess}
onConnectionTimeout={handleConnectionTimeout}
onConnectionError={handleConnectionError}
/>
</div>
);
@@ -68,50 +69,38 @@ function CloudAccountSetupModal({
includeAllRegions,
handleRegionSelect,
handleSubmit,
connectionCommand,
accountId,
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="outlined"
color="secondary"
onClick={requestClose}
testId="aws-cancel-btn"
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}
>
Cancel
Launch Cloud Formation Template
</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>
);
@@ -124,17 +113,22 @@ function CloudAccountSetupModal({
const viewConfigs = {
[ActiveViewEnum.FORM]: {
title: 'Add AWS Account',
okText: 'Generate Command',
okText: (
<div className="cloud-account-setup-form__submit-button-content">
Launch Cloud Formation Template{' '}
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
</div>
),
onOk: handleSubmit,
disabled:
isLoading || isGeneratingCommand || modalState === ModalStateEnum.WAITING,
isLoading || isGeneratingUrl || 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 || isGeneratingCommand,
isLoading: isLoading || isGeneratingUrl,
cancelButtonProps: { style: { display: 'block' } },
disabled: false,
},
@@ -146,7 +140,7 @@ function CloudAccountSetupModal({
handleSubmit,
getSelectedRegionsCount,
isLoading,
isGeneratingCommand,
isGeneratingUrl,
activeView,
setActiveView,
]);
@@ -155,32 +149,23 @@ function CloudAccountSetupModal({
const handleDrawerOpenChange = (open: boolean): void => {
if (!open) {
requestClose();
handleClose();
}
};
return (
<>
<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}
/>
</>
<DrawerWrapper
open={true}
className="cloud-account-setup-modal"
onOpenChange={handleDrawerOpenChange}
direction="right"
showCloseButton
title={modalConfig.title}
width="wide"
footer={footer}
>
{renderContent()}
</DrawerWrapper>
);
}

View File

@@ -11,9 +11,11 @@ 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">
@@ -34,6 +36,7 @@ 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,8 +1,14 @@
import { useRef } from 'react';
import { Form } from 'antd';
import { RegionFormProps } from 'container/Integrations/HeroSection/types';
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 { regions } from 'utils/regions';
import AgentSetupCommand from '../../AgentSetupCommand/AgentSetupCommand';
import AlertMessage from '../../AlertMessage';
import {
ComplianceNote,
@@ -16,13 +22,56 @@ export function RegionForm({
modalState,
selectedRegions,
onSubmit,
connectionCommand,
accountId,
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}
@@ -30,30 +79,28 @@ export function RegionForm({
layout="vertical"
onFinish={onSubmit}
>
{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"
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
})}
>
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
isFormDisabled={isFormDisabled}
/>
) : (
<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>
)}
<MonitoringRegionsSection
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={isFormDisabled}
/>
</div>
<div className="cloud-account-setup-form__alert">
<AlertMessage modalState={modalState} />

View File

@@ -5,9 +5,11 @@ import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigN
function RenderConnectionFields({
isConnectionParamsLoading,
connectionParams,
isFormDisabled,
}: {
isConnectionParamsLoading?: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO | null;
isFormDisabled?: boolean;
}): JSX.Element | null {
if (
isConnectionParamsLoading ||
@@ -27,7 +29,7 @@ function RenderConnectionFields({
label="Ingestion URL"
rules={[{ required: true, message: 'Please enter ingestion URL' }]}
>
<Input placeholder="Enter ingestion URL" />
<Input placeholder="Enter ingestion URL" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.ingestionKey && (
@@ -36,7 +38,7 @@ function RenderConnectionFields({
label="Ingestion Key"
rules={[{ required: true, message: 'Please enter ingestion key' }]}
>
<Input placeholder="Enter ingestion key" />
<Input placeholder="Enter ingestion key" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.sigNozApiUrl && (
@@ -45,7 +47,7 @@ function RenderConnectionFields({
label="SigNoz API URL"
rules={[{ required: true, message: 'Please enter SigNoz API URL' }]}
>
<Input placeholder="Enter SigNoz API URL" />
<Input placeholder="Enter SigNoz API URL" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.sigNozApiKey && (
@@ -54,7 +56,7 @@ function RenderConnectionFields({
label="SigNoz API KEY"
rules={[{ required: true, message: 'Please enter SigNoz API Key' }]}
>
<Input placeholder="Enter SigNoz API Key" />
<Input placeholder="Enter SigNoz API Key" disabled={isFormDisabled} />
</Form.Item>
)}
</Form.Item>
@@ -63,6 +65,7 @@ function RenderConnectionFields({
RenderConnectionFields.defaultProps = {
connectionParams: null,
isFormDisabled: false,
isConnectionParamsLoading: false,
};

View File

@@ -1,168 +0,0 @@
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,10 +1,23 @@
import { useCallback, useState } from 'react';
import { ChevronDown, ChevronRight, LoaderCircle } from '@signozhq/icons';
import { useCallback, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
LoaderCircle,
SquareArrowOutUpRight,
} 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 { AZURE_REGIONS } from 'container/Integrations/constants';
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 {
IntegrationModalProps,
ModalStateEnum,
@@ -12,13 +25,15 @@ 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 {
@@ -26,19 +41,53 @@ function CloudAccountSetupModal({
form,
modalState,
isLoading,
connectionCommand,
accountId,
connectionCommands,
handleSubmit,
handleClose,
connectionParams,
isConnectionParamsLoading,
checkConnectionAgain,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
{ shouldConfirm: Boolean(connectionCommand), onClose: handleClose },
);
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
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) {
@@ -47,7 +96,14 @@ function CloudAccountSetupModal({
<Callout
title={
<div className="cloud-account-setup-form__alert-message">
<Spin indicator={<LoaderCircle size={14} className="animate-spin" />} />
<Spin
indicator={
<LoaderCircle
size={14}
className="anticon anticon-loading anticon-spin ant-spin-dot"
/>
}
/>
Waiting for Azure account connection, retrying in{' '}
<span className="retry-time">10</span> secs...
</div>
@@ -80,233 +136,234 @@ function CloudAccountSetupModal({
const footer = (
<div className="cloud-account-setup-modal__footer">
<Button
size="md"
variant="outlined"
color="secondary"
onClick={requestClose}
testId="azure-cancel-btn"
>
Cancel
</Button>
{!connectionCommand && (
{modalState === ModalStateEnum.FORM && (
<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 Command
</Button>
)}
{modalState === ModalStateEnum.ERROR && connectionCommand && (
<Button
size="md"
variant="solid"
color="primary"
onClick={checkConnectionAgain}
testId="azure-check-connection-btn"
>
Check Again
Generate Azure Setup Commands
</Button>
)}
</div>
);
return (
<>
<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>
<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
</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)}
>
{isHowItWorksOpen ? <ChevronDown /> : <ChevronRight />}
</Button>
<span className="cloud-account-setup-how-it-works-accordion__title-text">
How it works?
<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)}
>
{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>
{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>
<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"
>
<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>
</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>
</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
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}
/>
</>
</Form>
</div>
</DrawerWrapper>
);
}

View File

@@ -1,175 +0,0 @@
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

@@ -1,59 +0,0 @@
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

@@ -1,30 +0,0 @@
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,7 +3,3 @@
align-items: center;
gap: var(--spacing-4);
}
.mono {
composes: mono from '../shared.module.scss';
}

View File

@@ -6,7 +6,6 @@ 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';
@@ -16,6 +15,7 @@ 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,15 +67,8 @@ function AgentSetup({
{connectionCommand ? (
<>
<AgentSetupCommand
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.
</>
}
deploymentProjectId={commandProjectId}
command={connectionCommand}
testId="gcp-agent-command"
/>
{modalState === ModalStateEnum.WAITING && (
<Callout

View File

@@ -10,6 +10,10 @@
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,28 +1,27 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import CodeBlock from 'components/CodeBlock/CodeBlock';
import styles from './AgentSetupCommand.module.scss';
interface AgentSetupCommandProps {
description: ReactNode;
deploymentProjectId: string;
command: string;
testId: string;
}
function AgentSetupCommand({
description,
deploymentProjectId,
command,
testId,
}: AgentSetupCommandProps): JSX.Element {
return (
<div className={styles.commands} data-testid={testId}>
<div className={styles.commands} data-testid="gcp-agent-command">
<div className={styles.commandsHead}>
<Typography.Text weight="bold" size="base">
Deploy the SigNoz agent
</Typography.Text>
<Typography.Text as="p" size="small" color="muted">
{description}
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.
</Typography.Text>
</div>
<CodeBlock language="bash" code={command} className={styles.command} />

View File

@@ -1,9 +1,10 @@
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';
@@ -17,9 +18,15 @@ function CloudAccountSetupDrawer({
);
// Set once the agent command exists; closing after that loses the pending setup.
const [hasGeneratedCommand, setHasGeneratedCommand] = useState(false);
const { isConfirmOpen, setIsConfirmOpen, requestClose } = useCloseSetupConfirm(
{ shouldConfirm: hasGeneratedCommand, onClose },
);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const requestClose = (): void => {
if (hasGeneratedCommand) {
setIsCloseConfirmOpen(true);
return;
}
onClose();
};
return (
<>
@@ -42,13 +49,39 @@ function CloudAccountSetupDrawer({
)
}
</SetupDrawerLayout>
<CloseSetupConfirm
providerName="GCP"
testIdPrefix="gcp"
open={isConfirmOpen}
onOpenChange={setIsConfirmOpen}
onConfirm={onClose}
/>
<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>
</>
);
}

View File

@@ -20,12 +20,18 @@ export interface RegionFormProps {
includeAllRegions: boolean;
onRegionSelect: () => void;
onSubmit: () => Promise<void>;
connectionCommand: string | null;
accountId?: string;
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,6 +121,13 @@
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,7 +5,6 @@ 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';
@@ -54,7 +53,6 @@ export function PlannedDowntime(): JSX.Element {
data: alertsData,
isError,
isLoading,
isFetching: isFetchingAlerts,
} = useListRules({
query: { cacheTime: 0 },
});
@@ -62,7 +60,6 @@ export function PlannedDowntime(): JSX.Element {
const [form] = Form.useForm();
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const location = useAppLocation();
const urlQuery = useUrlQuery();
const [initialValues, setInitialValues] =
@@ -86,31 +83,20 @@ export function PlannedDowntime(): JSX.Element {
}
}, [form, isOpen]);
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 [searchValue, setSearchValue] = React.useState<string>(
urlQuery.get('search') || '',
);
const [deleteData, setDeleteData] = useState<{ id: string; name: string }>();
const [isEditMode, setEditMode] = useState<boolean>(false);
const updateUrlWithSearch = useDebouncedFn((value) => {
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);
const searchValue = value as string;
if (searchValue) {
urlQuery.set('search', searchValue);
} else {
params.delete('search');
urlQuery.delete('search');
}
const url = `/alerts?${params.toString()}`;
const url = `/alerts?${urlQuery.toString()}`;
navigate(url, { replace: true });
}, 300);
@@ -141,11 +127,9 @@ 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, isAlertsLoading),
[formatTimezoneAdjustedTimestamp, isAlertsLoading],
() => getPlannedDowntimeColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const handleCreate = useCallback((): void => {
@@ -220,7 +204,7 @@ export function PlannedDowntime(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All planned downtimes
Planned Downtime
</Typography.Title>
<span className={styles.countPill}>{totalDowntimes}</span>
</div>
@@ -242,7 +226,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,7 +10,8 @@ import {
AlertmanagertypesRepeatTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { mockLocation } from 'container/RoutingPolicies/__tests__/testUtils';
import { mockAppLocation } from 'tests/router-mocks';
import { render, userEvent } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
@@ -101,6 +102,8 @@ const mockDowntimeQueryResult: Partial<DowntimeQueryResult> = {
let mockUrlQuery: URLSearchParams;
mockAppLocation({ pathname: '/alerts' });
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
@@ -124,44 +127,29 @@ 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: (): typeof mockRulesState => mockRulesState,
useListRules: (): {
data: { data: [] };
isError: false;
isLoading: false;
} => ({
data: { data: [] },
isError: false,
isLoading: false,
}),
}));
describe('PlannedDowntime Component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlQuery = new URLSearchParams();
mockAppLocation({
pathname: '/alerts',
search: '',
});
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
mockRulesState = {
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
mockLocation('/alerts');
});
it('renders the header, search and create action', () => {
renderPage();
expect(screen.getByText('All planned downtimes')).toBeInTheDocument();
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
expect(screen.getByTestId(SEARCH_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(CREATE_TEST_ID)).toHaveAttribute(
'aria-disabled',
@@ -205,14 +193,14 @@ describe('PlannedDowntime Component', () => {
expect(screen.getByText(MOCK_DOWNTIME_3_NAME)).toBeInTheDocument();
});
it('shows the key schedule details, timezone and duration, and hides secondary columns by default', async () => {
it('shows the key schedule details 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.getAllByText('Asia/Kolkata').length).toBeGreaterThan(0);
expect(screen.getAllByText('2h').length).toBeGreaterThan(0);
expect(screen.queryByText('Asia/Kolkata')).not.toBeInTheDocument();
expect(screen.queryByText('2h')).not.toBeInTheDocument();
expect(screen.queryByText('Database maintenance')).not.toBeInTheDocument();
expect(screen.queryByText('creator@signoz.io')).not.toBeInTheDocument();
expect(screen.queryByText('editor@signoz.io')).not.toBeInTheDocument();
@@ -231,9 +219,7 @@ describe('PlannedDowntime Component', () => {
'Downtime',
'Status',
'Schedule',
'Timezone',
'Repeats',
'Duration',
'Alerts Silenced',
]);
});
@@ -304,116 +290,4 @@ 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,8 +1,6 @@
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';
@@ -33,11 +31,8 @@ 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}>
@@ -71,7 +66,7 @@ export function getPlannedDowntimeColumns(
id: 'name',
header: 'Downtime',
accessorKey: 'name',
width: COLUMN_WIDTH,
width: { default: '100%', min: '200px' },
enableSort: true,
enableResize: true,
enableRemove: false,
@@ -89,7 +84,7 @@ export function getPlannedDowntimeColumns(
id: 'status',
header: 'Status',
accessorKey: 'status',
width: COLUMN_WIDTH,
width: { default: '110px', min: '100px' },
enableSort: true,
enableResize: true,
enableMove: false,
@@ -108,7 +103,7 @@ export function getPlannedDowntimeColumns(
id: 'window',
header: 'Schedule',
accessorFn: (row) => row.schedule?.startTime,
width: COLUMN_WIDTH,
width: { default: '340px', min: '220px' },
enableSort: true,
enableResize: true,
enableMove: false,
@@ -118,7 +113,8 @@ export function getPlannedDowntimeColumns(
id: 'timezone',
header: 'Timezone',
accessorFn: (row) => row.schedule?.timezone,
width: COLUMN_WIDTH,
width: { default: '130px', min: '110px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
enableMove: false,
@@ -128,7 +124,7 @@ export function getPlannedDowntimeColumns(
id: 'repeats',
header: 'Repeats',
accessorFn: (row) => row.schedule?.recurrence?.repeatType,
width: COLUMN_WIDTH,
width: { default: '180px', min: '120px' },
enableSort: false,
enableResize: true,
enableMove: false,
@@ -138,7 +134,8 @@ export function getPlannedDowntimeColumns(
id: 'duration',
header: 'Duration',
accessorFn: (row) => row.schedule?.recurrence?.duration,
width: COLUMN_WIDTH,
width: { default: '110px', min: '90px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
enableMove: false,
@@ -149,46 +146,42 @@ export function getPlannedDowntimeColumns(
id: 'alertsSilenced',
header: 'Alerts Silenced',
accessorKey: 'alertIds',
width: COLUMN_WIDTH,
width: { default: '200px', min: '140px' },
enableSort: false,
enableResize: true,
enableMove: false,
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,
}))}
/>
);
},
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>
),
},
{
id: 'description',
header: 'Description',
accessorKey: 'description',
width: COLUMN_WIDTH,
width: { default: '200px', min: '120px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
@@ -199,7 +192,7 @@ export function getPlannedDowntimeColumns(
id: 'createdBy',
header: 'Created By',
accessorKey: 'createdBy',
width: COLUMN_WIDTH,
width: { default: '180px', min: '120px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
@@ -210,7 +203,7 @@ export function getPlannedDowntimeColumns(
id: 'createdAt',
header: 'Created At',
accessorKey: 'createdAt',
width: COLUMN_WIDTH,
width: { default: '150px', min: '120px' },
defaultVisibility: false,
enableSort: true,
enableResize: true,
@@ -221,7 +214,7 @@ export function getPlannedDowntimeColumns(
id: 'updatedBy',
header: 'Updated By',
accessorKey: 'updatedBy',
width: COLUMN_WIDTH,
width: { default: '180px', min: '120px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
@@ -232,7 +225,7 @@ export function getPlannedDowntimeColumns(
id: 'updatedAt',
header: 'Updated At',
accessorKey: 'updatedAt',
width: COLUMN_WIDTH,
width: { default: '150px', min: '120px' },
defaultVisibility: false,
enableSort: true,
enableResize: true,

View File

@@ -5,6 +5,7 @@ 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 = {
@@ -71,38 +72,16 @@ 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) => {
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,
};
});
.map((downtime) => ({
...downtime,
alertOptions: getAlertOptionsFromIds(downtime.alertIds || [], alertOptions),
}));
}, [downtimes, alertOptions, searchValue]);
const pagedDowntimes = useMemo(() => {

View File

@@ -121,6 +121,13 @@
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}>
All routing policies
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('All routing policies')).toBeInTheDocument();
expect(screen.getByText('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', 'Channels', 'Expression']);
expect(resizable).toStrictEqual(['Routing Policy', 'Expression', 'Channels']);
});
it('opens the policy on row click', async () => {

View File

@@ -202,39 +202,6 @@ 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,8 +7,6 @@ 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>[] {
@@ -44,7 +42,7 @@ export function getRoutingPolicyColumns(
id: 'name',
header: 'Routing Policy',
accessorKey: 'name',
width: COLUMN_WIDTH,
width: { default: '100%', min: '220px' },
enableSort: true,
enableResize: true,
enableRemove: false,
@@ -58,31 +56,11 @@ 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: COLUMN_WIDTH,
width: { default: '260px', min: '160px' },
enableSort: false,
enableResize: true,
enableMove: false,
@@ -92,11 +70,37 @@ 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: COLUMN_WIDTH,
width: { default: '180px', min: '120px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
@@ -107,7 +111,7 @@ export function getRoutingPolicyColumns(
id: 'createdAt',
header: 'Created At',
accessorKey: 'createdAt',
width: COLUMN_WIDTH,
width: { default: '150px', min: '120px' },
defaultVisibility: false,
enableSort: true,
enableResize: true,
@@ -118,7 +122,7 @@ export function getRoutingPolicyColumns(
id: 'updatedBy',
header: 'Updated By',
accessorKey: 'updatedBy',
width: COLUMN_WIDTH,
width: { default: '180px', min: '120px' },
defaultVisibility: false,
enableSort: false,
enableResize: true,
@@ -129,7 +133,7 @@ export function getRoutingPolicyColumns(
id: 'updatedAt',
header: 'Updated At',
accessorKey: 'updatedAt',
width: COLUMN_WIDTH,
width: { default: '150px', min: '120px' },
defaultVisibility: false,
enableSort: true,
enableResize: true,

View File

@@ -1,6 +1,5 @@
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';
@@ -31,20 +30,9 @@ import './styles.scss';
function useRoutingPolicies(): UseRoutingPoliciesReturn {
const queryClient = useQueryClient();
const urlQuery = useUrlQuery();
const location = useAppLocation();
// Local state
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 [searchTerm, setSearchTerm] = useState(urlQuery.get('search') || '');
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [policyDetailsModalState, setPolicyDetailsModalState] =
useState<PolicyDetailsModalState>({
@@ -56,15 +44,12 @@ 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) {
params.set('search', searchValue);
urlQuery.set('search', searchValue);
} else {
params.delete('search');
urlQuery.delete('search');
}
const url = `/alerts?${params.toString()}`;
const url = `/alerts?${urlQuery.toString()}`;
navigate(url, { replace: true });
}, 300);

View File

@@ -1,43 +0,0 @@
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

@@ -14,10 +14,9 @@ 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';
@@ -30,7 +29,6 @@ import useAxiosError from 'hooks/useAxiosError';
import { regions } from 'utils/regions';
import logEvent from '../../../api/common/logEvent';
import { useAgentConnectionPolling } from '../useAgentConnectionPolling';
interface UseIntegrationModalProps {
onClose: () => void;
@@ -44,8 +42,7 @@ interface UseIntegrationModal {
activeView: ActiveViewEnum;
selectedRegions: string[];
includeAllRegions: boolean;
isGeneratingCommand: boolean;
connectionCommand: string | null;
isGeneratingUrl: boolean;
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
handleRegionSelect: () => void;
@@ -53,11 +50,17 @@ interface UseIntegrationModal {
handleClose: () => void;
setActiveView: (view: ActiveViewEnum) => void;
allRegions: string[];
accountId?: string;
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
isConnectionParamsLoading: boolean;
checkConnectionAgain: () => void;
handleConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
handleConnectionTimeout: (payload: { id?: string }) => void;
handleConnectionError: () => void;
}
export function useIntegrationModal({
@@ -70,9 +73,6 @@ 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,15 +103,14 @@ export function useIntegrationModal({
setSelectedRegions([]);
setIncludeAllRegions(false);
setModalState(ModalStateEnum.FORM);
setConnectionCommand(null);
onClose();
}, [onClose]);
const handleConnectionSuccess = useCallback(
(account: CloudintegrationtypesAccountDTO): void => {
(payload: { cloudAccountId: string; status?: unknown }): void => {
void logEvent('AWS Integration: Account connected', {
cloudAccountId: account.providerAccountId ?? account.id,
status: account.agentReport,
cloudAccountId: payload.cloudAccountId,
status: payload.status,
});
toast.success('AWS account connected successfully', {
position: 'bottom-right',
@@ -124,32 +123,21 @@ export function useIntegrationModal({
[handleClose, queryClient],
);
const handleConnectionTimeout = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('AWS Integration: Account connection attempt timed out', {
id: accountId,
});
}, [accountId]);
const handleConnectionTimeout = useCallback(
(payload: { id?: string }): void => {
setModalState(ModalStateEnum.ERROR);
void logEvent('AWS Integration: Account connection attempt timed out', {
id: payload.id,
});
},
[],
);
const handleConnectionError = useCallback((): void => {
setModalState(ModalStateEnum.ERROR);
}, []);
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 { mutate: generateUrl, isLoading: isGeneratingUrl } = useCreateAccount();
const handleError = useAxiosError();
const { data: connectionParams, isLoading: isConnectionParamsLoading } =
@@ -164,9 +152,9 @@ export function useIntegrationModal({
},
);
const handleGenerateCommand = useCallback(
const handleGenerateUrl = useCallback(
(payload: CloudintegrationtypesPostableAccountDTO): void => {
createAccount(
generateUrl(
{
pathParams: { cloudProvider: INTEGRATION_TYPES.AWS },
data: payload,
@@ -174,19 +162,17 @@ export function useIntegrationModal({
{
onSuccess: (response: CreateAccountMutationResult) => {
const accountId = response.data.id;
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;
}
const connectionUrl =
response.data.connectionArtifact.aws?.connectionUrl ?? '';
void logEvent('AWS Integration: Account connection command generated', {
id: accountId,
});
setConnectionCommand(command);
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');
setModalState(ModalStateEnum.WAITING);
setAccountId(accountId);
},
@@ -199,7 +185,7 @@ export function useIntegrationModal({
},
);
},
[createAccount],
[generateUrl],
);
const handleSubmit = useCallback(async (): Promise<void> => {
@@ -222,13 +208,13 @@ export function useIntegrationModal({
},
};
handleGenerateCommand(payload);
handleGenerateUrl(payload);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [form, selectedRegions, handleGenerateCommand, connectionParams]);
}, [form, selectedRegions, handleGenerateUrl, connectionParams]);
return {
form,
@@ -237,8 +223,7 @@ export function useIntegrationModal({
activeView,
selectedRegions,
includeAllRegions,
isGeneratingCommand,
connectionCommand,
isGeneratingUrl,
setSelectedRegions,
setIncludeAllRegions,
handleRegionSelect,
@@ -246,6 +231,7 @@ export function useIntegrationModal({
handleClose,
setActiveView,
allRegions,
accountId,
setModalState,
selectedDeploymentRegion,
handleRegionChange,
@@ -253,6 +239,8 @@ export function useIntegrationModal({
| CloudintegrationtypesCredentialsDTO
| undefined,
isConnectionParamsLoading,
checkConnectionAgain,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
};
}

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