mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-28 20:10:43 +00:00
Compare commits
12 Commits
tvats-quer
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9ad48079 | ||
|
|
5b6477900b | ||
|
|
1ce461d47f | ||
|
|
c0778e426a | ||
|
|
5266f089b9 | ||
|
|
d736f62033 | ||
|
|
bf7a6b44f7 | ||
|
|
b41df346db | ||
|
|
77d31cb122 | ||
|
|
82ee9953de | ||
|
|
05d7dd4e92 | ||
|
|
16a8a850d2 |
@@ -44,7 +44,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/noopauditstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/subscription"
|
||||
"github.com/SigNoz/signoz/pkg/subscription/noopsubscription"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
@@ -122,10 +121,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ context.Context, _ factory.ProviderSettings, _ flagger.Flagger, _ licensing.Licensing, _ telemetrystore.TelemetryStore, _ retention.Getter, _ organization.Getter, _ zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
|
||||
return signoz.NewMeterReporterProviderFactories(), "noop"
|
||||
},
|
||||
[]signoz.LogSourceQuery{{
|
||||
Source: telemetrytypes.SourceAudit,
|
||||
StatementBuilderFactory: noopauditstatementbuilder.NewFactory,
|
||||
}},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
},
|
||||
|
||||
@@ -28,9 +28,7 @@ import (
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
"github.com/SigNoz/signoz/ee/statementbuilder/auditstatementbuilder"
|
||||
"github.com/SigNoz/signoz/ee/subscription/httpsubscription"
|
||||
"github.com/SigNoz/signoz/ee/telemetrymetadata/audittelemetrymetadata"
|
||||
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
@@ -174,13 +172,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return factories, "http"
|
||||
},
|
||||
[]signoz.LogSourceQuery{{
|
||||
Source: telemetrytypes.SourceAudit,
|
||||
Resolver: func(ts telemetrystore.TelemetryStore) telemetrytypes.FieldKeyResolver {
|
||||
return audittelemetrymetadata.New(ts)
|
||||
},
|
||||
StatementBuilderFactory: auditstatementbuilder.NewFactory,
|
||||
}},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
|
||||
@@ -4224,6 +4224,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -5250,6 +5257,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -5265,6 +5273,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5278,6 +5287,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5384,6 +5394,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5494,6 +5516,16 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the slices are computed over.
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -5748,6 +5780,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -5914,6 +6044,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -31970,132 +32105,6 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
ai_traces_raw:
|
||||
summary: 'Raw: LLM, tool, and agent spans of one service''s AI traces
|
||||
with many tool calls'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
filter:
|
||||
expression: service.name = 'ai-agent' AND trace.tool_call_count
|
||||
> 10
|
||||
limit: 100
|
||||
name: A
|
||||
order:
|
||||
- direction: desc
|
||||
key:
|
||||
fieldContext: span
|
||||
name: timestamp
|
||||
signal: traces
|
||||
type: builder_ai_query
|
||||
end: 1640998800000
|
||||
requestType: raw
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
ai_traces_raw_select_fields:
|
||||
summary: 'Raw: the same spans with the span name and gen_ai model,
|
||||
tool, and token fields selected'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
filter:
|
||||
expression: service.name = 'ai-agent' AND trace.tool_call_count
|
||||
> 10
|
||||
limit: 100
|
||||
name: A
|
||||
order:
|
||||
- direction: desc
|
||||
key:
|
||||
fieldContext: span
|
||||
name: timestamp
|
||||
selectFields:
|
||||
- fieldContext: span
|
||||
name: name
|
||||
- fieldContext: attribute
|
||||
name: gen_ai.request.model
|
||||
- fieldContext: attribute
|
||||
name: gen_ai.tool.name
|
||||
- fieldContext: attribute
|
||||
name: gen_ai.usage.input_tokens
|
||||
- fieldContext: attribute
|
||||
name: gen_ai.usage.output_tokens
|
||||
signal: traces
|
||||
type: builder_ai_query
|
||||
end: 1640998800000
|
||||
requestType: raw
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
ai_traces_scalar:
|
||||
summary: 'Scalar: average per-trace AI cost by model'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- expression: avg(trace.estimated_total_cost)
|
||||
groupBy:
|
||||
- fieldContext: attribute
|
||||
name: gen_ai.request.model
|
||||
limit: 10
|
||||
name: A
|
||||
order:
|
||||
- direction: desc
|
||||
key:
|
||||
name: avg(trace.estimated_total_cost)
|
||||
signal: traces
|
||||
type: builder_ai_query
|
||||
end: 1640998800000
|
||||
requestType: scalar
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
ai_traces_time_series:
|
||||
summary: 'Time series: per-trace AI cost by model'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- expression: sum(trace.estimated_total_cost)
|
||||
groupBy:
|
||||
- fieldContext: attribute
|
||||
name: gen_ai.request.model
|
||||
limit: 10
|
||||
name: A
|
||||
order:
|
||||
- direction: desc
|
||||
key:
|
||||
name: sum(trace.estimated_total_cost)
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_ai_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
ai_traces_trace:
|
||||
summary: 'Trace list: AI traces with many tool calls, most expensive
|
||||
first'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
filter:
|
||||
expression: trace.tool_call_count > 10 AND trace.distinct_tool_count
|
||||
< 3
|
||||
limit: 10
|
||||
name: A
|
||||
order:
|
||||
- direction: desc
|
||||
key:
|
||||
name: trace.estimated_total_cost
|
||||
signal: traces
|
||||
type: builder_ai_query
|
||||
end: 1640998800000
|
||||
requestType: trace
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
clickhouse_sql_logs_raw:
|
||||
summary: 'ClickHouse SQL: raw logs with resource filter'
|
||||
value:
|
||||
|
||||
@@ -93,9 +93,9 @@ tests/
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
│ ├── auth.ts
|
||||
│ └── logs-explorer.ts
|
||||
│ └── dashboards.ts
|
||||
├── testdata/ # static data files (JSON) used by helpers and tests
|
||||
│ └── traces/large-trace.json # (example)
|
||||
│ └── apm-metrics.json # (example)
|
||||
├── tests/ # Playwright .spec.ts files, one dir per feature area
|
||||
│ └── alerts/
|
||||
│ └── alerts.spec.ts # (example)
|
||||
@@ -110,8 +110,8 @@ tests/
|
||||
These two folders look similar but mean different things:
|
||||
|
||||
- **`fixtures/`** holds *Playwright test fixtures* (created via `test.extend({...})`). By the canonical definition, a fixture is "a consistent, predefined set of data, objects, or environmental conditions used to ensure tests run in a stable state" — i.e. setup/teardown that runs *automatically* around each test or worker. `auth.ts` matches: it extends Playwright's `test` with an `authedPage` that's logged-in before every test runs and torn down after. If the only thing in this folder ever is `auth.ts`, that's fine — fixtures are a deliberately small surface.
|
||||
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoLogsExplorer(page)`) and the constants those helpers and the tests both refer to (e.g. `LINKED_ROW_CLASS`). Constants live next to the helpers that use them so a single import line in a test covers both.
|
||||
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `traces/large-trace.json`, a real trace the trace-details helpers seed.
|
||||
- **`helpers/`** holds plain function helpers that you call *explicitly* from a test or hook — they don't extend Playwright's `test`. This covers both behaviour helpers (e.g. `gotoDashboardsList(page)`) and the constants those helpers and the tests both refer to (e.g. `SEARCH_PLACEHOLDER`). Constants live next to the helpers that use them so a single import line in a test covers both.
|
||||
- **`testdata/`** holds static data files (typically JSON / YAML) consumed by the helpers — for example, `apm-metrics.json`, a real dashboard payload uploaded through the UI by an importer helper.
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
@@ -128,7 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
@@ -187,7 +187,7 @@ Each spec follows these principles:
|
||||
});
|
||||
```
|
||||
This is faster and more reliable than a multi-step UI seed. Reach for the UI flow only when the test's *purpose* is asserting that flow.
|
||||
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `traces/large-trace.json` is a real trace that `loadLargeTrace` (in `helpers/trace-details.ts`) seeds for the trace-details tests.
|
||||
6. **Reusable static data lives in `tests/e2e/testdata/`.** For example, `apm-metrics.json` is a real dashboard payload that `importApmMetricsDashboardViaUI` (in `helpers/dashboards.ts`) uploads through the actual Import JSON UI flow to seed a richly-tagged dashboard for search/list tests.
|
||||
|
||||
## How to write an E2E test?
|
||||
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
package audittelemetrymetadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/telemetryschema/audittelemetryschema"
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata/parser"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetadata/resolvercore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
var errFailedToGetAuditKeys = errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get audit keys")
|
||||
|
||||
// Resolver answers key and value lookups for the audit log tables. It is
|
||||
// registered against telemetrytypes.SourceAudit; without it the metadata store
|
||||
// carries no audit knowledge.
|
||||
type Resolver struct {
|
||||
store telemetrystore.TelemetryStore
|
||||
}
|
||||
|
||||
var _ telemetrytypes.FieldKeyResolver = (*Resolver)(nil)
|
||||
|
||||
func New(store telemetrystore.TelemetryStore) *Resolver {
|
||||
return &Resolver{store: store}
|
||||
}
|
||||
|
||||
func (r *Resolver) tblStatementToFieldKeys(ctx context.Context) ([]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "auditTblStatementToFieldKeys",
|
||||
})
|
||||
|
||||
query := fmt.Sprintf("SHOW CREATE TABLE %s.%s", audittelemetryschema.DBName, audittelemetryschema.AuditLogsTableName)
|
||||
statements := []telemetrytypes.ShowCreateTableStatement{}
|
||||
err := r.store.ClickhouseDB().Select(ctx, &statements, query)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, resolvercore.ErrFailedToGetTblStatement.Error())
|
||||
}
|
||||
|
||||
materialisedKeys, err := parser.ExtractFieldKeysFromTblStatement(statements[0].Statement)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, errFailedToGetAuditKeys.Error())
|
||||
}
|
||||
|
||||
for idx := range materialisedKeys {
|
||||
materialisedKeys[idx].Signal = telemetrytypes.SignalLogs
|
||||
}
|
||||
|
||||
return materialisedKeys, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) GetKeys(ctx context.Context, fieldKeySelectors []*telemetrytypes.FieldKeySelector) ([]*telemetrytypes.TelemetryFieldKey, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "getAuditKeys",
|
||||
})
|
||||
|
||||
if len(fieldKeySelectors) == 0 {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
matKeys, err := r.tblStatementToFieldKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
mapOfKeys := make(map[string]*telemetrytypes.TelemetryFieldKey)
|
||||
for _, key := range matKeys {
|
||||
mapOfKeys[key.Name+";"+key.FieldContext.StringValue()+";"+key.FieldDataType.StringValue()] = key
|
||||
}
|
||||
|
||||
var queries []string
|
||||
var allArgs []any
|
||||
|
||||
queryAttributeTable := false
|
||||
queryResourceTable := false
|
||||
|
||||
for _, selector := range fieldKeySelectors {
|
||||
if selector.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
queryAttributeTable = true
|
||||
queryResourceTable = true
|
||||
break
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextAttribute {
|
||||
queryAttributeTable = true
|
||||
} else if selector.FieldContext == telemetrytypes.FieldContextResource {
|
||||
queryResourceTable = true
|
||||
}
|
||||
}
|
||||
|
||||
tablesToQuery := []struct {
|
||||
fieldContext telemetrytypes.FieldContext
|
||||
shouldQuery bool
|
||||
tblName string
|
||||
}{
|
||||
{telemetrytypes.FieldContextAttribute, queryAttributeTable, audittelemetryschema.DBName + "." + audittelemetryschema.LogAttributeKeysTblName},
|
||||
{telemetrytypes.FieldContextResource, queryResourceTable, audittelemetryschema.DBName + "." + audittelemetryschema.LogResourceKeysTblName},
|
||||
}
|
||||
|
||||
for _, table := range tablesToQuery {
|
||||
if !table.shouldQuery {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldContext := table.fieldContext
|
||||
tblName := table.tblName
|
||||
|
||||
sb := sqlbuilder.Select(
|
||||
"name AS tag_key",
|
||||
fmt.Sprintf("'%s' AS tag_type", fieldContext.TagType()),
|
||||
"lower(datatype) AS tag_data_type",
|
||||
fmt.Sprintf("%d AS priority", resolvercore.PriorityForContext(fieldContext)),
|
||||
).From(tblName)
|
||||
|
||||
var limit int
|
||||
conds := []string{}
|
||||
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKeySelector.FieldContext != fieldContext {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldKeyConds := []string{}
|
||||
if fieldKeySelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("name", fieldKeySelector.Name))
|
||||
} else {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.ILike("name", "%"+clickhousesql.LikePattern(fieldKeySelector.Name)+"%"))
|
||||
}
|
||||
|
||||
if fieldKeySelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldKeyConds = append(fieldKeyConds, sb.E("datatype", fieldKeySelector.FieldDataType.TagDataType()))
|
||||
}
|
||||
|
||||
if len(fieldKeyConds) > 0 {
|
||||
conds = append(conds, sb.And(fieldKeyConds...))
|
||||
}
|
||||
limit += fieldKeySelector.Limit
|
||||
}
|
||||
|
||||
if len(conds) > 0 {
|
||||
sb.Where(sb.Or(conds...))
|
||||
}
|
||||
|
||||
sb.GroupBy("name", "datatype")
|
||||
if limit == 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
queries = append(queries, query)
|
||||
allArgs = append(allArgs, args...)
|
||||
}
|
||||
|
||||
var limit int
|
||||
for _, fieldKeySelector := range fieldKeySelectors {
|
||||
limit += fieldKeySelector.Limit
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
keys := []*telemetrytypes.TelemetryFieldKey{}
|
||||
rowCount := 0
|
||||
|
||||
// the log and scope contexts have no keys table; they are served by the
|
||||
// static fields appended below
|
||||
if len(queries) > 0 {
|
||||
mainQuery := fmt.Sprintf(`
|
||||
SELECT tag_key, tag_type, tag_data_type, max(priority) as priority
|
||||
FROM (
|
||||
%s
|
||||
) AS combined_results
|
||||
GROUP BY tag_key, tag_type, tag_data_type
|
||||
ORDER BY priority
|
||||
LIMIT %d
|
||||
`, strings.Join(queries, " UNION ALL "), limit+1)
|
||||
|
||||
rows, err := r.store.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, errFailedToGetAuditKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
if rowCount > limit {
|
||||
break
|
||||
}
|
||||
|
||||
var name string
|
||||
var fieldContext telemetrytypes.FieldContext
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
var priority uint8
|
||||
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, errFailedToGetAuditKeys.Error())
|
||||
}
|
||||
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
|
||||
|
||||
if !ok {
|
||||
key = &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
}
|
||||
}
|
||||
|
||||
keys = append(keys, key)
|
||||
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
|
||||
}
|
||||
|
||||
if rows.Err() != nil {
|
||||
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, errFailedToGetAuditKeys.Error())
|
||||
}
|
||||
}
|
||||
|
||||
complete := rowCount <= limit
|
||||
|
||||
// Add the matching intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
|
||||
for _, field := range maps.Values(audittelemetryschema.IntrinsicFields) {
|
||||
if !resolvercore.StaticFieldMatchesAny(field, fieldKeySelectors) {
|
||||
continue
|
||||
}
|
||||
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, &field)
|
||||
}
|
||||
|
||||
return keys, complete, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) GetAllValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "metadata",
|
||||
instrumentationtypes.CodeFunctionName: "getAuditFieldValues",
|
||||
})
|
||||
|
||||
limit := fieldValueSelector.Limit
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(audittelemetryschema.DBName + "." + audittelemetryschema.TagAttributesTableName)
|
||||
|
||||
if fieldValueSelector.Name != "" {
|
||||
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
|
||||
}
|
||||
|
||||
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
sb.Where(sb.E("tag_type", fieldValueSelector.FieldContext.TagType()))
|
||||
}
|
||||
|
||||
if fieldValueSelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
sb.Where(sb.E("tag_data_type", fieldValueSelector.FieldDataType.TagDataType()))
|
||||
}
|
||||
|
||||
if fieldValueSelector.Value != "" {
|
||||
switch fieldValueSelector.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
sb.Where(sb.ILike("string_value", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"))
|
||||
case telemetrytypes.FieldDataTypeNumber:
|
||||
sb.Where(sb.IsNotNull("number_value"))
|
||||
sb.Where(sb.ILike("toString(number_value)", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"))
|
||||
case telemetrytypes.FieldDataTypeUnspecified:
|
||||
sb.Where(sb.Or(
|
||||
sb.ILike("string_value", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"),
|
||||
sb.ILike("toString(number_value)", "%"+clickhousesql.LikePattern(fieldValueSelector.Value)+"%"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// fetch one extra row to detect whether the result set is complete
|
||||
sb.Limit(limit + 1)
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := r.store.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, errFailedToGetAuditKeys.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
values := &telemetrytypes.TelemetryFieldValues{}
|
||||
seen := make(map[string]bool)
|
||||
rowCount := 0
|
||||
totalCount := 0
|
||||
|
||||
for rows.Next() {
|
||||
rowCount++
|
||||
|
||||
var stringValue string
|
||||
var numberValue float64
|
||||
err = rows.Scan(&stringValue, &numberValue)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, errFailedToGetAuditKeys.Error())
|
||||
}
|
||||
if stringValue != "" && !seen[stringValue] {
|
||||
if totalCount >= limit {
|
||||
break
|
||||
}
|
||||
values.StringValues = append(values.StringValues, stringValue)
|
||||
seen[stringValue] = true
|
||||
totalCount++
|
||||
}
|
||||
if numberValue != 0 {
|
||||
if totalCount >= limit {
|
||||
break
|
||||
}
|
||||
if !seen[fmt.Sprintf("%f", numberValue)] {
|
||||
values.NumberValues = append(values.NumberValues, numberValue)
|
||||
seen[fmt.Sprintf("%f", numberValue)] = true
|
||||
totalCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
complete := rowCount <= limit
|
||||
|
||||
return values, complete, nil
|
||||
}
|
||||
@@ -127,7 +127,6 @@ const preview: Preview = {
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Panels',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
@@ -80,6 +82,7 @@
|
||||
"field_incidentio_description": "Description",
|
||||
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
|
||||
"incidentio_required_fields": "Alert source URL and token are required",
|
||||
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
|
||||
"field_incidentio_metadata": "Additional metadata",
|
||||
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
|
||||
"placeholder_incidentio_metadata_key": "Key",
|
||||
@@ -89,8 +92,8 @@
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
"field_slack_description": "Description",
|
||||
"field_webhook_username": "Username",
|
||||
"field_webhook_password": "Password",
|
||||
"field_webhook_username": "User Name (optional)",
|
||||
"field_webhook_password": "Password (optional)",
|
||||
"field_pager_routing_key": "Routing Key",
|
||||
"field_pager_description": "Description",
|
||||
"field_pager_severity": "Severity",
|
||||
@@ -109,10 +112,13 @@
|
||||
"help_pager_group": "A cluster or grouping of sources",
|
||||
"help_pager_component": "The part or component of the affected system that is broke",
|
||||
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
|
||||
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
||||
"help_webhook_password": "Specify a password or bearer token",
|
||||
"help_pager_description": "Shows up as description in pagerduty",
|
||||
"channel_creation_done": "Successfully created the channel",
|
||||
"channel_edit_done": "Channels Edited Successfully",
|
||||
"selected_channel_invalid": "Channel type selected is invalid",
|
||||
"username_no_password": "A Password must be provided with user name",
|
||||
"channel_test_done": "An alert has been sent to this channel",
|
||||
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
|
||||
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
|
||||
|
||||
@@ -25,9 +25,11 @@
|
||||
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net. Only Jira Cloud is supported.",
|
||||
"jira_site_invalid": "Site URL must be an https URL on an atlassian.net domain",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
@@ -88,8 +90,7 @@
|
||||
"field_incidentio_description": "Description",
|
||||
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
|
||||
"incidentio_required_fields": "Alert source URL and token are required",
|
||||
"incidentio_token_bearer_prefix": "Paste the token only, without the Bearer prefix",
|
||||
"incidentio_token_whitespace": "Remove the spaces around the token",
|
||||
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
|
||||
"field_incidentio_metadata": "Additional metadata",
|
||||
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
|
||||
"placeholder_incidentio_metadata_key": "Key",
|
||||
@@ -115,7 +116,6 @@
|
||||
"field_slack_field_short": "Short",
|
||||
"add_slack_field": "Add field",
|
||||
"remove_slack_field": "Remove field",
|
||||
"slack_field_value_required": "Enter a value for this field",
|
||||
"field_slack_actions": "Actions",
|
||||
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
|
||||
"label_slack_action_text": "Text",
|
||||
@@ -133,7 +133,6 @@
|
||||
"placeholder_slack_action_style": "Style: default, primary or danger",
|
||||
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
|
||||
"add_slack_action": "Add action",
|
||||
"slack_action_url_or_name_required": "Enter a URL or a name for this button",
|
||||
"remove_slack_action": "Remove action",
|
||||
"field_opsgenie_api_key": "API Key",
|
||||
"field_opsgenie_description": "Description",
|
||||
@@ -144,15 +143,9 @@
|
||||
"help_email_html": "Send email in html format",
|
||||
"field_email_html": "Email body template",
|
||||
"placeholder_email_html": "Email body template",
|
||||
"field_webhook_username": "Username",
|
||||
"field_webhook_password": "Password",
|
||||
"field_webhook_bearer_token": "Bearer token",
|
||||
"field_webhook_auth": "Authentication",
|
||||
"option_webhook_auth_none": "None",
|
||||
"option_webhook_auth_basic": "Basic auth",
|
||||
"option_webhook_auth_bearer": "Bearer token",
|
||||
"webhook_basic_auth_required": "Enter a username or password",
|
||||
"webhook_bearer_token_required": "Enter a bearer token",
|
||||
"field_webhook_username": "User Name (optional)",
|
||||
"field_webhook_password": "Password (optional)",
|
||||
"field_webhook_bearer_token": "Bearer token (optional)",
|
||||
"field_pager_routing_key": "Routing Key",
|
||||
"field_pager_description": "Description",
|
||||
"field_pager_severity": "Severity",
|
||||
@@ -175,7 +168,9 @@
|
||||
"help_pager_group": "A cluster or grouping of sources",
|
||||
"help_pager_component": "The part or component of the affected system that is broke",
|
||||
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header.",
|
||||
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
|
||||
"help_webhook_password": "Specify a password or bearer token",
|
||||
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
|
||||
"help_pager_description": "Shows up as description in pagerduty",
|
||||
"help_opsgenie_message": "Shows up as message in opsgenie",
|
||||
"help_opsgenie_priority": "Priority of the incident",
|
||||
@@ -183,6 +178,7 @@
|
||||
"channel_creation_done": "Successfully created the channel",
|
||||
"channel_edit_done": "Channels Edited Successfully",
|
||||
"selected_channel_invalid": "Channel type selected is invalid",
|
||||
"username_no_password": "A Password must be provided with user name",
|
||||
"channel_test_done": "An alert has been sent to this channel",
|
||||
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
|
||||
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getBuilderVariant } from 'lib/query/queryMode';
|
||||
import {
|
||||
BuilderVariant,
|
||||
IBuilderTraceOperator,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from './queryBuilderFields.types';
|
||||
@@ -59,8 +64,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[config],
|
||||
);
|
||||
|
||||
const isAIQuery =
|
||||
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
|
||||
// AI queries are traces-only; BAR must not force metrics on them.
|
||||
if (isAIQuery) {
|
||||
handleSetConfig(newPanelType, DataSource.TRACES);
|
||||
return;
|
||||
}
|
||||
if (newPanelType === PANEL_TYPES.BAR) {
|
||||
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
|
||||
return;
|
||||
}
|
||||
handleSetConfig(newPanelType, currentDataSource);
|
||||
}
|
||||
}, [
|
||||
@@ -69,6 +86,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
initialDataSource,
|
||||
currentDataSource,
|
||||
newPanelType,
|
||||
isAIQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderV2 } from '../QueryBuilderV2';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
jest.mock('../QueryV2/QueryV2', () => ({ QueryV2: (): null => null }));
|
||||
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
|
||||
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
|
||||
const aiQuery: Query = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
function mockContext({
|
||||
panelType = null,
|
||||
initialDataSource = null,
|
||||
currentQuery = initialQueriesMap.metrics,
|
||||
}: {
|
||||
panelType?: PANEL_TYPES | null;
|
||||
initialDataSource?: DataSource | null;
|
||||
currentQuery?: Query;
|
||||
}): jest.Mock {
|
||||
const handleSetConfig = jest.fn();
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
currentQuery,
|
||||
panelType,
|
||||
initialDataSource,
|
||||
handleSetConfig,
|
||||
addNewBuilderQuery: jest.fn(),
|
||||
addNewFormula: jest.fn(),
|
||||
addTraceOperator: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
} as unknown as ReturnType<typeof useQueryBuilder>);
|
||||
return handleSetConfig;
|
||||
}
|
||||
|
||||
describe('QueryBuilderV2 provider config sync', () => {
|
||||
it('leaves the data source unset for a bar panel without a static config', () => {
|
||||
const handleSetConfig = mockContext({});
|
||||
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.BAR} version="v3" />);
|
||||
|
||||
expect(handleSetConfig).toHaveBeenCalledWith(PANEL_TYPES.BAR, null);
|
||||
});
|
||||
|
||||
it('syncs the static data source', () => {
|
||||
const handleSetConfig = mockContext({});
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
version="v3"
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(handleSetConfig).toHaveBeenCalledWith(
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.LOGS,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an AI query on traces for a bar panel', () => {
|
||||
const handleSetConfig = mockContext({ currentQuery: aiQuery });
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
version="v3"
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(handleSetConfig).toHaveBeenCalledWith(
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.TRACES,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reset the config when the provider already matches', () => {
|
||||
const handleSetConfig = mockContext({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
initialDataSource: DataSource.TRACES,
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryBuilderV2
|
||||
version="v3"
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(handleSetConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ interface ToggleAction {
|
||||
isOnlyOrAllClicked?: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
quickFilterSource?: QuickFiltersSource;
|
||||
source?: QuickFiltersSource;
|
||||
attributeValues?: string[];
|
||||
}
|
||||
|
||||
@@ -117,8 +117,7 @@ function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
|
||||
currentQuery: buildQuery(initialItems, initialExpression),
|
||||
activeQueryIndex: 0,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
quickFilterSource:
|
||||
c.action.quickFilterSource ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
|
||||
value: c.action.value,
|
||||
checked: c.action.checked,
|
||||
@@ -163,7 +162,7 @@ const TOGGLE_CASES: ToggleCase[] = [
|
||||
action: {
|
||||
value: 'a',
|
||||
checked: false,
|
||||
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
// `nin` is what the source asks for, but re-deriving the expression
|
||||
// normalises it. Nothing observes the difference: both infra pages send
|
||||
@@ -314,7 +313,7 @@ const TOGGLE_CASES: ToggleCase[] = [
|
||||
action: {
|
||||
value: 'b',
|
||||
checked: false,
|
||||
quickFilterSource: QuickFiltersSource.INFRA_MONITORING,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
|
||||
@@ -46,18 +46,14 @@ export function removeManagedClauses(expression: string, key: string): string {
|
||||
}
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS: QuickFiltersSource[] = [
|
||||
QuickFiltersSource.INFRA_MONITORING,
|
||||
];
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
/**
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
export function getNotInOperator(
|
||||
quickFilterSource: QuickFiltersSource,
|
||||
): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(quickFilterSource)) {
|
||||
export function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
@@ -138,7 +134,7 @@ export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
quickFilterSource,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
@@ -149,7 +145,7 @@ export function applyCheckboxToggle({
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
filter: IQuickFiltersConfig;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
value: string;
|
||||
checked: boolean;
|
||||
@@ -244,7 +240,7 @@ export function applyCheckboxToggle({
|
||||
if (sectionType === SectionType.RELATED) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(quickFilterSource),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
@@ -384,7 +380,7 @@ export function applyCheckboxToggle({
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(quickFilterSource),
|
||||
op: getNotInOperator(source),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
@@ -408,9 +404,7 @@ export function applyCheckboxToggle({
|
||||
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: checked
|
||||
? getOperatorValue(OPERATORS.IN)
|
||||
: getNotInOperator(quickFilterSource),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { SectionType } from './v2/itemRules';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
source: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
@@ -38,7 +38,7 @@ interface UseCheckboxFilterActionsReturn {
|
||||
*/
|
||||
function useCheckboxFilterActions({
|
||||
filter,
|
||||
quickFilterSource,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
@@ -65,7 +65,7 @@ function useCheckboxFilterActions({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
quickFilterSource,
|
||||
source,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
};
|
||||
|
||||
@@ -156,7 +158,7 @@ export function renderWithFilter(
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
|
||||
@@ -33,7 +33,7 @@ import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
@@ -42,18 +42,13 @@ interface CheckboxFilterV2Props {
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const {
|
||||
quickFilterSource,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -80,7 +75,7 @@ export default function CheckboxFilterV2(
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
quickFilterSource,
|
||||
source,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
@@ -109,7 +104,7 @@ export default function CheckboxFilterV2(
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
quickFilterSource,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -44,7 +44,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -59,8 +59,6 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
expect(params.get('endUnixMilli')).toBe(
|
||||
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
|
||||
);
|
||||
expect(params.get('signal')).toBeNull();
|
||||
expect(params.get('source')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps non-AI sources on the signal-wide endpoint', async () => {
|
||||
@@ -72,7 +70,7 @@ describe('CheckboxFilterV2 - AI observability routing', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
@@ -82,7 +82,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
@@ -116,7 +116,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -154,7 +154,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -222,7 +222,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -264,7 +264,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
} from '../CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - field APIs', () => {
|
||||
it('sends the signal and source of the quick filter source to the signal-wide endpoint', async () => {
|
||||
const requests: URLSearchParams[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { values: { stringValues: ['ingest'] } },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.METER_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(screen.findByText('ingest')).resolves.toBeInTheDocument();
|
||||
expect(requests[0].get('signal')).toBe(TelemetrytypesSignalDTO.metrics);
|
||||
expect(requests[0].get('source')).toBe(TelemetrytypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -101,7 +101,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -119,7 +119,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -173,7 +173,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -240,7 +240,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -288,7 +288,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -330,7 +330,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -394,7 +394,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -440,7 +440,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -468,7 +468,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -490,7 +490,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -516,7 +516,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -568,7 +568,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -601,7 +601,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -627,7 +627,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -652,7 +652,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -691,7 +691,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -714,7 +714,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -761,7 +761,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -837,7 +837,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -971,7 +971,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -55,7 +55,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -106,7 +106,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -140,7 +140,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -185,7 +185,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -221,7 +221,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -273,7 +273,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -321,7 +321,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
@@ -373,7 +373,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -424,7 +424,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -466,7 +466,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -521,7 +521,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -47,7 +47,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -104,7 +104,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -133,7 +133,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -152,7 +152,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -172,7 +172,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -195,7 +195,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
quickFilterSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldValuesConfig } from 'api/querySuggestions/types';
|
||||
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
source?: QuickFiltersSource;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
@@ -38,13 +38,12 @@ export function useFieldValues({
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
quickFilterSource,
|
||||
source,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const isAIObservability =
|
||||
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY;
|
||||
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
@@ -60,12 +59,14 @@ export function useFieldValues({
|
||||
endUnixMilli,
|
||||
}
|
||||
: {
|
||||
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[quickFilterSource],
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source: QUICK_FILTERS_SOURCE_TO_SOURCE[quickFilterSource],
|
||||
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
|
||||
@@ -31,11 +31,11 @@ export type { FilterType };
|
||||
function Duration({
|
||||
filter,
|
||||
onFilterChange,
|
||||
quickFilterSource,
|
||||
source,
|
||||
}: {
|
||||
filter: IQuickFiltersConfig;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
source: QuickFiltersSource;
|
||||
}): JSX.Element {
|
||||
const [selectedFilters, setSelectedFilters] =
|
||||
useState<
|
||||
@@ -52,7 +52,7 @@ function Duration({
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const syncSelectedFilters = useMemo((): FilterType => {
|
||||
|
||||
@@ -49,18 +49,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
className,
|
||||
config,
|
||||
handleFilterVisibilityChange,
|
||||
quickFilterSource,
|
||||
source,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
savedViewsHeader,
|
||||
} = props;
|
||||
const savedFiltersSource =
|
||||
quickFilterSource === QuickFiltersSource.INFRA_MONITORING
|
||||
? undefined
|
||||
: quickFilterSource;
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const showIP = params.showIP ?? true;
|
||||
@@ -71,7 +68,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
} = useFilterConfig({ savedFiltersSource, config });
|
||||
} = useFilterConfig({ signal, config });
|
||||
const {
|
||||
deniedPermissions: deniedSettingsPermissions,
|
||||
isLoading: isSettingsChecking,
|
||||
@@ -115,15 +112,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
// Show dropdown in ListView only for TRACES_EXPLORER source
|
||||
const shouldShowDropdownInListView =
|
||||
isListView && quickFilterSource === QuickFiltersSource.TRACES_EXPLORER;
|
||||
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
|
||||
|
||||
// AI observability builds a single query in the row-level views, so there is
|
||||
// no query for the selector to switch between.
|
||||
const isAIObservabilityRowView =
|
||||
quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY &&
|
||||
source === QuickFiltersSource.AI_OBSERVABILITY &&
|
||||
(isListView || panelType === PANEL_TYPES.TRACE);
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(quickFilterSource);
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
// clear all the filters for the query which is in sync with filters
|
||||
const handleReset = (): void => {
|
||||
@@ -293,7 +290,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderContent = (): JSX.Element => (
|
||||
<>
|
||||
{quickFilterSource === QuickFiltersSource.API_MONITORING && (
|
||||
{source === QuickFiltersSource.API_MONITORING && (
|
||||
<div className="api-quick-filters-header">
|
||||
<Typography.Text>Show IP addresses</Typography.Text>
|
||||
<Switch
|
||||
@@ -316,7 +313,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
quickFilterSource={quickFilterSource}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
@@ -327,7 +324,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
quickFilterSource={quickFilterSource}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
@@ -366,14 +363,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
<div className="quick-filters-container">
|
||||
<div className="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{quickFilterSource !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
{source !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
<section className="header">
|
||||
{renderLeftActions()}
|
||||
{renderRightActions()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{savedFiltersSource ? (
|
||||
{signal ? (
|
||||
<AuthZGuardContent
|
||||
checks={[QuickFilterReadPermission]}
|
||||
fallbackOnLoading={filtersSkeleton}
|
||||
@@ -394,9 +391,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isSettingsOpen && savedFiltersSource && (
|
||||
{isSettingsOpen && (
|
||||
<QuickFiltersSettings
|
||||
savedFiltersSource={savedFiltersSource}
|
||||
signal={signal}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
customFilters={customFilters}
|
||||
refetchCustomFilters={refetchCustomFilters}
|
||||
@@ -410,6 +407,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
QuickFilters.defaultProps = {
|
||||
onFilterChange: null,
|
||||
signal: '',
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
|
||||
@@ -34,17 +34,17 @@ jest.mock('container/SavedViews/SavedViewsRestore', () => ({
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
quickFilterSource,
|
||||
source,
|
||||
savedViewsHeader,
|
||||
handleFilterVisibilityChange,
|
||||
}: {
|
||||
quickFilterSource: string;
|
||||
source: string;
|
||||
savedViewsHeader?: React.ReactNode;
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="quick-filters">
|
||||
{savedViewsHeader}
|
||||
{quickFilterSource}
|
||||
{source}
|
||||
{handleFilterVisibilityChange && (
|
||||
<Button
|
||||
size="md"
|
||||
@@ -62,7 +62,7 @@ jest.mock('../../QuickFilters', () => ({
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
quickFilterSource: QuickFiltersSource.TRACES_EXPLORER,
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
useFieldApis: { startUnixMilli: 0, endUnixMilli: 0 },
|
||||
};
|
||||
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { FieldKeysConfig } from 'api/querySuggestions/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QUICK_FILTER_SOURCE_TO_SIGNAL } from 'components/QuickFilters/constants';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
|
||||
import {
|
||||
@@ -15,6 +13,7 @@ import {
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -33,20 +32,18 @@ function OtherFiltersSkeleton(): JSX.Element {
|
||||
}
|
||||
|
||||
function OtherFilters({
|
||||
savedFiltersSource,
|
||||
signal,
|
||||
inputValue,
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
}: {
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO;
|
||||
signal: SignalType | undefined;
|
||||
inputValue: string;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isMeterDataSource =
|
||||
savedFiltersSource === QuickfiltertypesSourceDTO.meter;
|
||||
const isAIObservability =
|
||||
savedFiltersSource === QuickfiltertypesSourceDTO.ai_observability;
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
|
||||
|
||||
const builderQueryType: BuilderQueryType | undefined = isAIObservability
|
||||
? 'builder_ai_query'
|
||||
@@ -56,7 +53,9 @@ function OtherFilters({
|
||||
? { searchText: inputValue }
|
||||
: {
|
||||
searchText: inputValue,
|
||||
signal: QUICK_FILTER_SOURCE_TO_SIGNAL[savedFiltersSource],
|
||||
signal: signal
|
||||
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
|
||||
: undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
import useQuickFilterSettings from './hooks/useQuickFilterSettings';
|
||||
import OtherFilters from './OtherFilters';
|
||||
@@ -12,12 +12,12 @@ import OtherFilters from './OtherFilters';
|
||||
import './QuickFiltersSettings.styles.scss';
|
||||
|
||||
function QuickFiltersSettings({
|
||||
savedFiltersSource,
|
||||
signal,
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
}: {
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO;
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -37,7 +37,7 @@ function QuickFiltersSettings({
|
||||
setIsSettingsOpen,
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
savedFiltersSource,
|
||||
signal,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -73,7 +73,7 @@ function QuickFiltersSettings({
|
||||
setAddedFilters={setAddedFilters}
|
||||
/>
|
||||
<OtherFilters
|
||||
savedFiltersSource={savedFiltersSource}
|
||||
signal={signal}
|
||||
inputValue={debouncedInputValue}
|
||||
addedFilters={addedFilters}
|
||||
setAddedFilters={setAddedFilters}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { SignalType } from '../../types';
|
||||
import OtherFilters from '../OtherFilters';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
@@ -43,12 +43,10 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
function renderOtherFilters(
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO,
|
||||
): void {
|
||||
function renderOtherFilters(signal: SignalType): void {
|
||||
render(
|
||||
<OtherFilters
|
||||
savedFiltersSource={savedFiltersSource}
|
||||
signal={signal}
|
||||
inputValue=""
|
||||
addedFilters={[]}
|
||||
setAddedFilters={jest.fn()}
|
||||
@@ -57,7 +55,7 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
}
|
||||
|
||||
it('reads AI observability keys from their own endpoint', async () => {
|
||||
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
|
||||
await expect(
|
||||
screen.findByText('gen_ai.request.model'),
|
||||
@@ -66,7 +64,7 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
});
|
||||
|
||||
it('does not narrow the AI keys by fieldContext', async () => {
|
||||
renderOtherFilters(QuickfiltertypesSourceDTO.ai_observability);
|
||||
renderOtherFilters(SignalType.AI_OBSERVABILITY);
|
||||
|
||||
// A `trace` context would return only the computed per-trace aggregates,
|
||||
// which cannot be filtered on.
|
||||
@@ -75,7 +73,7 @@ describe('OtherFilters - AI observability keys', () => {
|
||||
});
|
||||
|
||||
it('keeps other signals on the signal-wide keys endpoint', async () => {
|
||||
renderOtherFilters(QuickfiltertypesSourceDTO.traces);
|
||||
renderOtherFilters(SignalType.TRACES);
|
||||
|
||||
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
|
||||
await waitFor(() => expect(aiKeysParams).toBeUndefined());
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const SIGNAL_DATA_SOURCE_MAP = {
|
||||
[SignalType.LOGS]: DataSource.LOGS,
|
||||
[SignalType.TRACES]: DataSource.TRACES,
|
||||
[SignalType.EXCEPTIONS]: DataSource.TRACES,
|
||||
[SignalType.API_MONITORING]: DataSource.TRACES,
|
||||
[SignalType.METER_EXPLORER]: DataSource.METRICS,
|
||||
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
|
||||
};
|
||||
@@ -4,8 +4,8 @@ import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
QuickfiltertypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
@@ -16,7 +16,7 @@ interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
savedFiltersSource: QuickfiltertypesSourceDTO;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
@@ -37,7 +37,7 @@ const useQuickFilterSettings = ({
|
||||
customFilters,
|
||||
setIsSettingsOpen,
|
||||
refetchCustomFilters,
|
||||
savedFiltersSource,
|
||||
signal,
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
@@ -113,19 +113,21 @@ const useQuickFilterSettings = ({
|
||||
);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: savedFiltersSource },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}, [addedFilters, savedFiltersSource, updateCustomFilters]);
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: signal },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [addedFilters, signal, updateCustomFilters]);
|
||||
|
||||
return {
|
||||
handleSettingsClose,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { QuickFiltersSource } from './types';
|
||||
|
||||
export const QUICK_FILTER_SOURCE_TO_SIGNAL: Record<
|
||||
QuickFiltersSource,
|
||||
TelemetrytypesSignalDTO
|
||||
> = {
|
||||
[QuickFiltersSource.LOGS_EXPLORER]: TelemetrytypesSignalDTO.logs,
|
||||
[QuickFiltersSource.TRACES_EXPLORER]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.EXCEPTIONS]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.API_MONITORING]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.AI_OBSERVABILITY]: TelemetrytypesSignalDTO.traces,
|
||||
[QuickFiltersSource.METER_EXPLORER]: TelemetrytypesSignalDTO.metrics,
|
||||
[QuickFiltersSource.INFRA_MONITORING]: TelemetrytypesSignalDTO.metrics,
|
||||
};
|
||||
@@ -10,26 +10,26 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
|
||||
* mode track the last focused query.
|
||||
*/
|
||||
function useActiveQueryIndex(quickFilterSource: QuickFiltersSource): number {
|
||||
function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
const { lastUsedQuery, panelType } = useQueryBuilder();
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
// AI observability builds a single query in the row-level views, so its
|
||||
// filters always drive the first one there.
|
||||
if (quickFilterSource === QuickFiltersSource.AI_OBSERVABILITY) {
|
||||
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
|
||||
return isListView || panelType === PANEL_TYPES.TRACE
|
||||
? 0
|
||||
: lastUsedQuery || 0;
|
||||
}
|
||||
|
||||
if (isListView) {
|
||||
return quickFilterSource === QuickFiltersSource.TRACES_EXPLORER
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, panelType, quickFilterSource, lastUsedQuery]);
|
||||
}, [isListView, panelType, source, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { IQuickFiltersConfig } from '../types';
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
|
||||
interface UseFilterConfigProps {
|
||||
savedFiltersSource?: QuickfiltertypesSourceDTO;
|
||||
signal?: SignalType;
|
||||
config: IQuickFiltersConfig[];
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
@@ -19,7 +18,7 @@ interface UseFilterConfigReturn {
|
||||
}
|
||||
|
||||
const useFilterConfig = ({
|
||||
savedFiltersSource,
|
||||
signal,
|
||||
config,
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
@@ -27,8 +26,8 @@ const useFilterConfig = ({
|
||||
data,
|
||||
refetch,
|
||||
} = useGetQuickFilters(
|
||||
{ source: savedFiltersSource ?? '' },
|
||||
{ query: { enabled: !!savedFiltersSource } },
|
||||
{ source: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
@@ -36,11 +35,11 @@ const useFilterConfig = ({
|
||||
[data],
|
||||
);
|
||||
|
||||
const isDynamicFilters = !!savedFiltersSource;
|
||||
const isDynamicFilters = !!signal;
|
||||
|
||||
const filterConfig = useMemo(
|
||||
() => getFilterConfig(customFilters, config),
|
||||
[config, customFilters],
|
||||
() => getFilterConfig(signal, customFilters, config),
|
||||
[config, customFilters, signal],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -89,15 +89,6 @@ export const handlers = [
|
||||
fieldValuesHandler(['checkout', 'frontend', 'payments']),
|
||||
];
|
||||
|
||||
export const noSavedFiltersHandler = rest.get(
|
||||
'http://localhost/api/v2/quick_filters/logs',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json(quickFiltersResponse(QuickfiltertypesSourceDTO.logs, [])),
|
||||
),
|
||||
);
|
||||
|
||||
export const loadingFiltersHandlers = [
|
||||
rest.get('http://localhost/api/v2/quick_filters/logs', (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
loadingFiltersHandlers,
|
||||
queryBuilder,
|
||||
selectedServiceQueryBuilder,
|
||||
noSavedFiltersHandler,
|
||||
} from './QuickFilters.stories.mocks';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
import { QuickFiltersSource, SignalType } from '../types';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Quick Filters',
|
||||
@@ -34,7 +33,8 @@ const meta = {
|
||||
args: {
|
||||
config: checkboxConfig,
|
||||
handleFilterVisibilityChange: (): void => undefined,
|
||||
quickFilterSource: QuickFiltersSource.LOGS_EXPLORER,
|
||||
signal: SignalType.LOGS,
|
||||
source: QuickFiltersSource.LOGS_EXPLORER,
|
||||
useFieldApis: fieldApis,
|
||||
},
|
||||
parameters: {
|
||||
@@ -94,16 +94,13 @@ export const LoadingFilters: Story = {
|
||||
|
||||
/** Empty: a loaded quick-filter configuration with no filters has no result rows. */
|
||||
export const NoResults: Story = {
|
||||
args: { config: [] },
|
||||
parameters: {
|
||||
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
|
||||
},
|
||||
args: { config: [], signal: undefined },
|
||||
};
|
||||
|
||||
/** Selection: an expanded checkbox shows the actual selected service values. */
|
||||
export const SelectedExpandedCheckbox: Story = {
|
||||
args: { signal: undefined },
|
||||
parameters: {
|
||||
msw: { handlers: [noSavedFiltersHandler, ...handlers] },
|
||||
signoz: {
|
||||
queryBuilder: selectedServiceQueryBuilder,
|
||||
},
|
||||
@@ -114,18 +111,12 @@ export const SelectedExpandedCheckbox: Story = {
|
||||
* Every tooltip the panel renders, held open: the reveal on each truncated
|
||||
* filter value. Nothing bounds those values, so the panel is answered with
|
||||
* service names long enough to be cut. The Service name filter carries them, so
|
||||
* the workspace's own saved filter list is answered empty.
|
||||
* the signal that would add the workspace's own dynamic filters is left off.
|
||||
*/
|
||||
export const Tooltips: TooltipsStory = {
|
||||
args: { tooltipsOpen: true },
|
||||
args: { signal: undefined, tooltipsOpen: true },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
noSavedFiltersHandler,
|
||||
fieldValuesHandler(LONG_FILTER_VALUES),
|
||||
...handlers,
|
||||
],
|
||||
},
|
||||
msw: { handlers: [fieldValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { QuickFiltersSource } from '../types';
|
||||
import { QuickFiltersSource, SignalType } from '../types';
|
||||
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -35,7 +35,8 @@ jest.mock('container/ApiMonitoring/queryParams');
|
||||
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const fieldsKeysURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -80,10 +81,11 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderWithSavedList(): void {
|
||||
function renderWithSignal(): void {
|
||||
render(
|
||||
<QuickFilters
|
||||
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
signal={SIGNAL}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
@@ -93,7 +95,7 @@ function renderWithSavedList(): void {
|
||||
function renderStaticConfig(): void {
|
||||
render(
|
||||
<QuickFilters
|
||||
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={QuickFiltersConfig}
|
||||
handleFilterVisibilityChange={jest.fn()}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
@@ -106,7 +108,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
it('shows the inline denial instead of the filters, header stays', async () => {
|
||||
server.use(setupAuthzDeny(QuickFilterReadPermission));
|
||||
|
||||
renderWithSavedList();
|
||||
renderWithSignal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(NOT_AUTHORIZED_TEXT),
|
||||
@@ -131,7 +133,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
it('renders the filters but disables the settings trigger', async () => {
|
||||
server.use(setupAuthzDeny(QuickFilterUpdatePermission));
|
||||
|
||||
renderWithSavedList();
|
||||
renderWithSignal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -156,7 +158,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
it('renders the filters and opens settings from the trigger', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
renderWithSavedList();
|
||||
renderWithSignal();
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -174,7 +176,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('static config pages (no saved list)', () => {
|
||||
describe('static config pages (no signal)', () => {
|
||||
it('is not gated even when every permission is denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
@@ -193,7 +195,7 @@ describe('QuickFilters - AuthZ', () => {
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
|
||||
renderWithSavedList();
|
||||
renderWithSignal();
|
||||
|
||||
await waitFor(() =>
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import {
|
||||
ApiMonitoringParams,
|
||||
useApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { getHostsQuickFiltersConfig } from 'container/InfraMonitoringHostsV2/utils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
@@ -19,7 +16,7 @@ import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource } from '../types';
|
||||
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
|
||||
import { QuickFiltersConfig, QuickFiltersFieldApis } from './constants';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -36,9 +33,9 @@ const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
|
||||
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/logs`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/logs`;
|
||||
const anyQuickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/:source`;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -57,9 +54,6 @@ const setupServer = (): void => {
|
||||
rest.get(quickFiltersListURL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersListResponse)),
|
||||
),
|
||||
rest.get(anyQuickFiltersListURL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: { filters: [] } })),
|
||||
),
|
||||
rest.get(quickFiltersSuggestionsURL, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(otherFiltersResponse)),
|
||||
),
|
||||
@@ -74,24 +68,48 @@ const setupServer = (): void => {
|
||||
};
|
||||
|
||||
function TestQuickFilters({
|
||||
quickFilterSource = QuickFiltersSource.EXCEPTIONS,
|
||||
signal = SignalType.LOGS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
quickFilterSource?: QuickFiltersSource;
|
||||
signal?: SignalType;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
quickFilterSource={quickFilterSource}
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFilters.defaultProps = {
|
||||
quickFilterSource: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: '',
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
function TestQuickFiltersApiMonitoring({
|
||||
signal = SignalType.LOGS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
signal?: SignalType;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
signal={signal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFiltersApiMonitoring.defaultProps = {
|
||||
signal: '',
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
@@ -269,14 +287,10 @@ describe('Quick Filters', () => {
|
||||
it('toggles Show IP addresses and updates API Monitoring params', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.API_MONITORING} />,
|
||||
);
|
||||
render(<TestQuickFiltersApiMonitoring />);
|
||||
|
||||
// Switch should be rendered and initially checked
|
||||
await expect(
|
||||
screen.findByText('Show IP addresses'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
@@ -294,9 +308,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('loads the custom filters correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
|
||||
expect(screen.getByText('Filters for')).toBeInTheDocument();
|
||||
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
|
||||
@@ -356,9 +368,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -386,9 +396,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -409,9 +417,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -440,9 +446,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('restores original filter state on Discard', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -484,9 +488,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('saves the updated filters by calling PUT with correct payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -523,9 +525,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||
|
||||
@@ -579,7 +579,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
describe('Quick Filters refetch behavior', () => {
|
||||
it('fetches custom filters on every mount for a page with a saved list', async () => {
|
||||
it('fetches custom filters on every mount when signal is provided', async () => {
|
||||
let getCalls = 0;
|
||||
|
||||
server.use(
|
||||
@@ -589,18 +589,14 @@ describe('Quick Filters refetch behavior', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -608,24 +604,19 @@ describe('Quick Filters refetch behavior', () => {
|
||||
expect(getCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('does not fetch custom filters for a page without a saved list', async () => {
|
||||
it('does not fetch custom filters when signal is undefined', async () => {
|
||||
let getCalls = 0;
|
||||
|
||||
server.use(
|
||||
rest.get(anyQuickFiltersListURL, (_req, res, ctx) => {
|
||||
rest.get(quickFiltersListURL, (_req, res, ctx) => {
|
||||
getCalls += 1;
|
||||
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.INFRA_MONITORING} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={undefined} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(getCalls).toBe(0);
|
||||
await waitFor(() => expect(getCalls).toBe(0));
|
||||
});
|
||||
|
||||
it('refetches custom filters after saving settings', async () => {
|
||||
@@ -644,9 +635,7 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -698,9 +687,7 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<TestQuickFilters quickFilterSource={QuickFiltersSource.LOGS_EXPLORER} />,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -731,47 +718,10 @@ describe('Quick Filters refetch behavior', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestQuickFilters
|
||||
quickFilterSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
config={[]}
|
||||
/>,
|
||||
);
|
||||
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText('No filters found'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Filters field APIs', () => {
|
||||
it('sends the page signal for every filter, the infra Environment filter included', async () => {
|
||||
const requests: URLSearchParams[] = [];
|
||||
server.use(
|
||||
rest.get(fieldsValuesURL, (req, res, ctx) => {
|
||||
requests.push(req.url.searchParams);
|
||||
return res(ctx.status(200), ctx.json(quickFiltersFieldValuesResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<QuickFilters
|
||||
quickFilterSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig()}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={QuickFiltersFieldApis}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(requests.map((params) => params.get('name'))).toContain(
|
||||
INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
|
||||
),
|
||||
);
|
||||
const environment = requests.find(
|
||||
(params) =>
|
||||
params.get('name') === INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
|
||||
);
|
||||
expect(environment?.get('signal')).toBe(TelemetrytypesSignalDTO.metrics);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QuickfiltertypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export enum FiltersType {
|
||||
SLIDER = 'SLIDER',
|
||||
@@ -19,6 +19,15 @@ export enum SpecficFilterOperations {
|
||||
ONLY = 'ONLY',
|
||||
}
|
||||
|
||||
export enum SignalType {
|
||||
TRACES = 'traces',
|
||||
LOGS = 'logs',
|
||||
API_MONITORING = 'api_monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai_observability',
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
|
||||
*/
|
||||
@@ -30,6 +39,7 @@ export interface IQuickFiltersConfig {
|
||||
attributeKey: BaseAutocompleteData;
|
||||
aggregateOperator?: string;
|
||||
aggregateAttribute?: string;
|
||||
dataSource?: DataSource;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
@@ -43,9 +53,11 @@ export interface QuickFilterChangeEventData {
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange?: () => void;
|
||||
quickFilterSource: QuickFiltersSource;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
/** Pass to fetch quick filters for this signal; omit to use `config` as-is */
|
||||
signal?: SignalType;
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
@@ -53,19 +65,15 @@ export interface IQuickFiltersProps {
|
||||
savedViewsHeader?: ReactNode;
|
||||
}
|
||||
|
||||
export const QuickFiltersSource = {
|
||||
LOGS_EXPLORER: QuickfiltertypesSourceDTO.logs,
|
||||
TRACES_EXPLORER: QuickfiltertypesSourceDTO.traces,
|
||||
EXCEPTIONS: QuickfiltertypesSourceDTO.exceptions,
|
||||
API_MONITORING: QuickfiltertypesSourceDTO.api_monitoring,
|
||||
METER_EXPLORER: QuickfiltertypesSourceDTO.meter,
|
||||
AI_OBSERVABILITY: QuickfiltertypesSourceDTO.ai_observability,
|
||||
/** No saved filter list: renders `config` as given. */
|
||||
INFRA_MONITORING: 'infra-monitoring',
|
||||
} as const;
|
||||
|
||||
export type QuickFiltersSource =
|
||||
(typeof QuickFiltersSource)[keyof typeof QuickFiltersSource];
|
||||
export enum QuickFiltersSource {
|
||||
LOGS_EXPLORER = 'logs-explorer',
|
||||
INFRA_MONITORING = 'infra-monitoring',
|
||||
TRACES_EXPLORER = 'traces-explorer',
|
||||
API_MONITORING = 'api-monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
AI_OBSERVABILITY = 'ai-observability',
|
||||
}
|
||||
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig } from './types';
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
|
||||
const FILTER_TITLE_MAP: Record<string, string> = {
|
||||
duration_nano: 'Duration',
|
||||
@@ -49,10 +50,11 @@ const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length) {
|
||||
if (!customFilters?.length || !signal) {
|
||||
return config || [];
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ export const getFilterConfig = (
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.name),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
|
||||
@@ -334,7 +334,6 @@ function AnomalyAlertEvaluationView({
|
||||
<Checkbox
|
||||
color="primary"
|
||||
key={seriesKey}
|
||||
testId="anomaly-series-option"
|
||||
name="series"
|
||||
value={selectedSeries === seriesKey}
|
||||
onChange={(): void => handleSeriesChange(seriesKey)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
@@ -24,7 +24,8 @@ function Explorer(): JSX.Element {
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
quickFilterSource: QuickFiltersSource.API_MONITORING,
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showQueryName: false,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
|
||||
@@ -91,6 +91,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -101,6 +102,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
@@ -111,6 +113,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/s
|
||||
|
||||
import { toChannelConfig, toPostableChannel } from './channelConfig';
|
||||
import { toChannelFormState } from './channelFormValues';
|
||||
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
|
||||
const slackValues: ChannelFormValues = {
|
||||
name: 'prod alerts',
|
||||
@@ -56,22 +56,6 @@ describe('toChannelConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[WebhookAuth.none, {}],
|
||||
[WebhookAuth.basic, { username: 'u', password: 'p' }],
|
||||
[WebhookAuth.bearer, { bearerToken: 't' }],
|
||||
])('sends only the %s webhook credentials', (webhookAuth, credentials) => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.webhook, {
|
||||
url: 'https://example.com/hook',
|
||||
webhookAuth,
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
bearerToken: 't',
|
||||
}).spec,
|
||||
).toStrictEqual({ url: 'https://example.com/hook', ...credentials });
|
||||
});
|
||||
|
||||
it('drops an attachment list the user emptied instead of sending []', () => {
|
||||
expect(
|
||||
toChannelConfig(ChannelKind.slack, {
|
||||
|
||||
@@ -18,17 +18,8 @@ import {
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { omitBlank } from 'utils/omitBlank';
|
||||
|
||||
import {
|
||||
ChannelFormValues,
|
||||
ChannelKind,
|
||||
ChannelSpecFormValues,
|
||||
WebhookAuth,
|
||||
} from './types';
|
||||
import {
|
||||
canonicalJiraReopenDuration,
|
||||
filledRows,
|
||||
webhookAuthOf,
|
||||
} from './utils';
|
||||
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
|
||||
import { canonicalJiraReopenDuration, filledRows } from './utils';
|
||||
|
||||
const SPEC_FIELDS = {
|
||||
[ChannelKind.slack]: [
|
||||
@@ -219,17 +210,6 @@ export function toChannelConfig(
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === ChannelKind.webhook) {
|
||||
const auth = webhookAuthOf(values);
|
||||
if (auth !== WebhookAuth.basic) {
|
||||
delete picked.username;
|
||||
delete picked.password;
|
||||
}
|
||||
if (auth !== WebhookAuth.bearer) {
|
||||
delete picked.bearerToken;
|
||||
}
|
||||
}
|
||||
|
||||
const spec: Record<string, unknown> = omitBlank(picked);
|
||||
|
||||
// the API answers a read with the duration prometheus would print, and
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelFormValues, ChannelKind } from './types';
|
||||
import { webhookAuthOf } from './utils';
|
||||
|
||||
export interface ChannelFormState {
|
||||
kind: ChannelKind;
|
||||
@@ -23,15 +22,11 @@ function toChannelKind(kind: string): ChannelKind {
|
||||
export function toChannelFormState(
|
||||
channel: AlertmanagertypesGettableNotificationChannelDTO,
|
||||
): ChannelFormState {
|
||||
const kind = toChannelKind(channel.config.kind);
|
||||
const values: ChannelFormValues = {
|
||||
...channel.config.spec,
|
||||
name: channel.displayName,
|
||||
return {
|
||||
kind: toChannelKind(channel.config.kind),
|
||||
values: {
|
||||
...channel.config.spec,
|
||||
name: channel.displayName,
|
||||
},
|
||||
};
|
||||
|
||||
if (kind === ChannelKind.webhook) {
|
||||
values.webhookAuth = webhookAuthOf(values);
|
||||
}
|
||||
|
||||
return { kind, values };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import { get, set } from 'lodash-es';
|
||||
import type { FieldErrors, Resolver } from 'react-hook-form';
|
||||
import type { $ZodIssue } from 'zod/v4/core';
|
||||
|
||||
@@ -11,12 +10,12 @@ const toFieldErrors = (
|
||||
issues: $ZodIssue[],
|
||||
t: TFunction,
|
||||
): FieldErrors<ChannelFormValues> => {
|
||||
const errors = {};
|
||||
const errors: Record<string, { type: string; message: string }> = {};
|
||||
|
||||
issues.forEach((issue) => {
|
||||
const path = issue.path.length ? issue.path : ['name'];
|
||||
if (!get(errors, path)) {
|
||||
set(errors, path, { type: 'validation', message: t(issue.message) });
|
||||
const field = issue.path.join('.') || 'name';
|
||||
if (!errors[field]) {
|
||||
errors[field] = { type: 'validation', message: t(issue.message) };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const SPECS: Record<string, Record<string, unknown>> = {
|
||||
url: 'https://example.com/hook',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
bearerToken: 't',
|
||||
sendResolved: true,
|
||||
},
|
||||
email: {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidGoogleChatWebhookURL } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const googleChatSchema = z.object({
|
||||
webhookUrl: requiredString('webhook_url_required'),
|
||||
webhookUrl: requiredString('webhook_url_required').refine(
|
||||
isValidGoogleChatWebhookURL,
|
||||
{ message: 'google_chat_webhook_url_invalid' },
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidIncidentIOURL } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
const required = requiredString('incidentio_required_fields');
|
||||
|
||||
export const incidentIOSchema = z.object({
|
||||
url: required,
|
||||
token: z
|
||||
.string({ error: 'incidentio_required_fields' })
|
||||
.refine((token) => token === token.trim(), {
|
||||
message: 'incidentio_token_whitespace',
|
||||
})
|
||||
.pipe(
|
||||
required.refine((token) => !/^bearer( |$)/i.test(token), {
|
||||
message: 'incidentio_token_bearer_prefix',
|
||||
}),
|
||||
),
|
||||
url: required.refine(isValidIncidentIOURL, {
|
||||
message: 'incidentio_url_invalid',
|
||||
}),
|
||||
token: required,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isValidJiraReopenDuration } from '../utils';
|
||||
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
const required = requiredString('jira_required_fields');
|
||||
|
||||
export const jiraSchema = z.object({
|
||||
site: required,
|
||||
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
|
||||
email: required,
|
||||
apiToken: required,
|
||||
project: required,
|
||||
|
||||
@@ -2,30 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
import { requiredString } from './shared';
|
||||
|
||||
// rows with a blank title or text are dropped on save, so only the rest are checked
|
||||
const fieldRow = z
|
||||
.object({ title: z.string().optional(), value: z.string().optional() })
|
||||
.refine((row) => !row.title?.trim() || !!row.value?.trim(), {
|
||||
message: 'slack_field_value_required',
|
||||
path: ['value'],
|
||||
});
|
||||
|
||||
const actionRow = z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(row) => !row.text?.trim() || !!row.url?.trim() || !!row.name?.trim(),
|
||||
{
|
||||
message: 'slack_action_url_or_name_required',
|
||||
path: ['url'],
|
||||
},
|
||||
);
|
||||
|
||||
export const slackSchema = z.object({
|
||||
apiUrl: requiredString('webhook_url_required'),
|
||||
fields: z.array(fieldRow).nullish(),
|
||||
actions: z.array(actionRow).nullish(),
|
||||
});
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { WebhookAuth } from '../types';
|
||||
import { webhookAuthOf } from '../utils';
|
||||
import { requiredString } from './shared';
|
||||
|
||||
export const webhookSchema = z
|
||||
.object({
|
||||
url: requiredString('webhook_url_required'),
|
||||
webhookAuth: z.enum(WebhookAuth).optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
bearerToken: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(values) =>
|
||||
webhookAuthOf(values) !== WebhookAuth.basic ||
|
||||
!!values.username?.trim() ||
|
||||
!!values.password?.trim(),
|
||||
{ message: 'webhook_basic_auth_required', path: ['username'] },
|
||||
)
|
||||
.refine(
|
||||
(values) =>
|
||||
webhookAuthOf(values) !== WebhookAuth.bearer || !!values.bearerToken?.trim(),
|
||||
{ message: 'webhook_bearer_token_required', path: ['bearerToken'] },
|
||||
);
|
||||
// the API allows bearer-only and no-auth webhooks, but a username without its
|
||||
// password is still an incomplete basic auth pair
|
||||
.refine((v) => !v.username || !!v.password, {
|
||||
message: 'username_no_password',
|
||||
path: ['password'],
|
||||
});
|
||||
|
||||
@@ -31,15 +31,6 @@ export type ChannelSpecFormValues = Partial<
|
||||
AlertmanagertypesChannelTelegramConfigDTO
|
||||
>;
|
||||
|
||||
export const WebhookAuth = {
|
||||
none: 'none',
|
||||
basic: 'basic',
|
||||
bearer: 'bearer',
|
||||
} as const;
|
||||
export type WebhookAuth = (typeof WebhookAuth)[keyof typeof WebhookAuth];
|
||||
|
||||
export type ChannelFormValues = ChannelSpecFormValues & {
|
||||
name?: string;
|
||||
/** Never sent: the API reads the mode from which credentials are set. */
|
||||
webhookAuth?: WebhookAuth;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
import { ChannelFormValues, WebhookAuth } from './types';
|
||||
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
|
||||
|
||||
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
|
||||
|
||||
export const isValidJiraSiteURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' &&
|
||||
hostname.toLowerCase().endsWith(JIRA_CLOUD_HOST_SUFFIX)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// mirrors go's prometheus model.Duration units
|
||||
const JIRA_DURATION_UNIT_MS: Record<string, number> = {
|
||||
@@ -69,21 +94,21 @@ export const canonicalJiraReopenDuration = (value: string): string => {
|
||||
}, '');
|
||||
};
|
||||
|
||||
export const webhookAuthOf = (
|
||||
values: Pick<
|
||||
ChannelFormValues,
|
||||
'webhookAuth' | 'username' | 'password' | 'bearerToken'
|
||||
>,
|
||||
): WebhookAuth => {
|
||||
if (values.webhookAuth) {
|
||||
return values.webhookAuth;
|
||||
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
|
||||
|
||||
// the backend enforces the same rule, this is only for a nicer error experience
|
||||
export const isValidIncidentIOURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, pathname } = new URL(url);
|
||||
const idx = pathname.indexOf(INCIDENTIO_EVENTS_PATH_PREFIX);
|
||||
return (
|
||||
protocol === 'https:' &&
|
||||
idx !== -1 &&
|
||||
pathname.length > idx + INCIDENTIO_EVENTS_PATH_PREFIX.length
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (values.bearerToken) {
|
||||
return WebhookAuth.bearer;
|
||||
}
|
||||
return values.username || values.password
|
||||
? WebhookAuth.basic
|
||||
: WebhookAuth.none;
|
||||
};
|
||||
|
||||
export const filledRows = <T>(
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { ChannelFormValues, ChannelKind, WebhookAuth } from './types';
|
||||
import { validateChannel } from './validation';
|
||||
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
|
||||
describe('validateChannel', () => {
|
||||
it.each<[string, ChannelKind, ChannelFormValues, string | null]>([
|
||||
[
|
||||
'a google chat webhook behind a proxy',
|
||||
ChannelKind.googlechat,
|
||||
{ webhookUrl: 'http://relay.internal/chat' },
|
||||
null,
|
||||
],
|
||||
[
|
||||
'a jira site that is not on atlassian.net',
|
||||
ChannelKind.jira,
|
||||
{
|
||||
site: 'https://jira-proxy.acme.com/',
|
||||
email: 'a@acme.com',
|
||||
apiToken: 't',
|
||||
project: 'OPS',
|
||||
issueType: 'Bug',
|
||||
},
|
||||
null,
|
||||
],
|
||||
[
|
||||
'any incident.io url',
|
||||
ChannelKind.incidentio,
|
||||
{ url: 'https://relay.internal/incidentio', token: 'tok' },
|
||||
null,
|
||||
],
|
||||
[
|
||||
'an incident.io token pasted with its Bearer prefix',
|
||||
ChannelKind.incidentio,
|
||||
{ url: 'https://relay.internal/incidentio', token: 'Bearer tok' },
|
||||
'incidentio_token_bearer_prefix',
|
||||
],
|
||||
[
|
||||
'an incident.io token with a trailing space',
|
||||
ChannelKind.incidentio,
|
||||
{ url: 'https://relay.internal/incidentio', token: 'tok ' },
|
||||
'incidentio_token_whitespace',
|
||||
],
|
||||
[
|
||||
'a webhook username without a password',
|
||||
ChannelKind.webhook,
|
||||
{ url: 'https://hook', webhookAuth: WebhookAuth.basic, username: 'u' },
|
||||
null,
|
||||
],
|
||||
[
|
||||
'basic auth with neither username nor password',
|
||||
ChannelKind.webhook,
|
||||
{ url: 'https://hook', webhookAuth: WebhookAuth.basic },
|
||||
'webhook_basic_auth_required',
|
||||
],
|
||||
[
|
||||
'bearer auth without a token',
|
||||
ChannelKind.webhook,
|
||||
{ url: 'https://hook', webhookAuth: WebhookAuth.bearer, username: 'u' },
|
||||
'webhook_bearer_token_required',
|
||||
],
|
||||
[
|
||||
'a slack field without a value',
|
||||
ChannelKind.slack,
|
||||
{ apiUrl: 'https://hooks', fields: [{ title: 'env', value: '' }] },
|
||||
'slack_field_value_required',
|
||||
],
|
||||
[
|
||||
'a slack button with neither url nor name',
|
||||
ChannelKind.slack,
|
||||
{
|
||||
apiUrl: 'https://hooks',
|
||||
actions: [{ type: 'button', text: 'Runbook', url: '' }],
|
||||
},
|
||||
'slack_action_url_or_name_required',
|
||||
],
|
||||
[
|
||||
'a slack button with a name only',
|
||||
ChannelKind.slack,
|
||||
{
|
||||
apiUrl: 'https://hooks',
|
||||
actions: [{ type: 'button', text: 'Ack', name: 'ack' }],
|
||||
},
|
||||
null,
|
||||
],
|
||||
])('%s', (_, kind, values, expected) => {
|
||||
expect(validateChannel(kind, { name: 'channel', ...values }, t)).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
// The setup guide button only exists in the classic form.
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string; search: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -21,7 +20,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
LicensePlatform,
|
||||
LicenseResModel,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -11,10 +8,6 @@ import SelectAlertType from '..';
|
||||
|
||||
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
|
||||
|
||||
const CLOUD_LICENSE = {
|
||||
platform: LicensePlatform.CLOUD,
|
||||
} as LicenseResModel;
|
||||
|
||||
describe('SelectAlertType', () => {
|
||||
const mockOnSelect = jest.fn();
|
||||
|
||||
@@ -26,7 +19,15 @@ describe('SelectAlertType', () => {
|
||||
it('should render all alert type options when anomaly detection is enabled', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
activeLicense: CLOUD_LICENSE,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
@@ -38,19 +39,6 @@ describe('SelectAlertType', () => {
|
||||
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should offer the anomaly based alert on an enterprise self-hosted license', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
activeLicense: {
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
} as LicenseResModel,
|
||||
});
|
||||
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
|
||||
expect(screen.getByText('anomaly_based_alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all alert type options except anomaly based alert when anomaly detection is disabled', () => {
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
|
||||
@@ -74,7 +62,15 @@ describe('SelectAlertType', () => {
|
||||
it('should call onSelect with anomaly based alert type', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
activeLicense: CLOUD_LICENSE,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SelectAlertType onSelect={mockOnSelect} />);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -16,8 +17,11 @@ import { OptionType } from './types';
|
||||
|
||||
function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
|
||||
const { t } = useTranslation(['alerts']);
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
|
||||
const isAnomalyDetectionEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
|
||||
const optionList = getOptionList(t, isAnomalyDetectionEnabled);
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import {
|
||||
LicensePlatform,
|
||||
LicenseResModel,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
@@ -78,10 +75,6 @@ const useUrlQuerySpy = jest.spyOn(useUrlQueryHooks, 'default');
|
||||
const useSafeNavigateSpy = jest.spyOn(navigateHooks, 'useSafeNavigate');
|
||||
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
|
||||
|
||||
const CLOUD_LICENSE = {
|
||||
platform: LicensePlatform.CLOUD,
|
||||
} as LicenseResModel;
|
||||
|
||||
const mockSetUrlQuery = jest.fn();
|
||||
const mockToString = jest.fn();
|
||||
const mockGetUrlQuery = jest.fn();
|
||||
@@ -159,7 +152,7 @@ describe('CreateAlertRule', () => {
|
||||
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
|
||||
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
@@ -170,9 +163,8 @@ describe('CreateAlertRule', () => {
|
||||
return null;
|
||||
});
|
||||
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();
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
@@ -280,7 +272,15 @@ describe('CreateAlertRule', () => {
|
||||
it('should navigate with anomaly detection params for anomaly alert', () => {
|
||||
useAppContextSpy.mockReturnValue({
|
||||
...getAppContextMockState({}),
|
||||
activeLicense: CLOUD_LICENSE,
|
||||
featureFlags: [
|
||||
{
|
||||
name: FeatureKeys.ANOMALY_DETECTION,
|
||||
active: true,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<CreateAlertRule />);
|
||||
|
||||
@@ -41,7 +41,7 @@ function CreateRules(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isTypeSelectionMode) {
|
||||
void logEvent('Alert: New alert data source selection page visited', {});
|
||||
logEvent('Alert: New alert data source selection page visited', {});
|
||||
}
|
||||
}, [isTypeSelectionMode]);
|
||||
|
||||
@@ -100,7 +100,10 @@ function CreateRules(): JSX.Element {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
if (
|
||||
showClassicCreateAlertsPageFlag ||
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
:global(.ant-tooltip).tabTooltip {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.switchNote {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
@@ -1,30 +1,20 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Activity, ChartLine } from '@signozhq/icons';
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
||||
import Stepper from '../Stepper';
|
||||
import { useIsAnomalyDetectionAvailable } from '../useIsAnomalyDetectionAvailable';
|
||||
import AlertThreshold from './AlertThreshold';
|
||||
import AnomalyThreshold from './AnomalyThreshold';
|
||||
import {
|
||||
ANOMALY_TAB_SWITCH_NOTE,
|
||||
ANOMALY_TAB_TOOLTIP,
|
||||
THRESHOLD_TAB_SWITCH_NOTE,
|
||||
THRESHOLD_TAB_TOOLTIP,
|
||||
} from './constants';
|
||||
import { useAnomalyFunctionSync } from './useAnomalyFunctionSync';
|
||||
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
||||
|
||||
import styles from './AlertCondition.module.scss';
|
||||
import './styles.scss';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
useAnomalyFunctionSync();
|
||||
|
||||
const {
|
||||
data: channels,
|
||||
@@ -33,12 +23,9 @@ function AlertCondition(): JSX.Element {
|
||||
refetch: refreshChannels,
|
||||
} = useChannelOptions();
|
||||
|
||||
const isAnomalyDetectionEnabled = useIsAnomalyDetectionAvailable();
|
||||
|
||||
// Anomaly rules stay editable even without a cloud or enterprise license.
|
||||
const showMultipleTabs =
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
|
||||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -46,15 +33,16 @@ function AlertCondition(): JSX.Element {
|
||||
icon: <ChartLine size={14} data-testid="threshold-view" />,
|
||||
value: AlertTypes.METRICS_BASED_ALERT,
|
||||
},
|
||||
...(showMultipleTabs
|
||||
? [
|
||||
{
|
||||
label: 'Anomaly',
|
||||
icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Hide anomaly tab for now
|
||||
// ...(showMultipleTabs
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Anomaly',
|
||||
// icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
// value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
];
|
||||
|
||||
const handleAlertTypeChange = (value: AlertTypes): void => {
|
||||
@@ -64,23 +52,11 @@ function AlertCondition(): JSX.Element {
|
||||
setAlertType(value);
|
||||
};
|
||||
|
||||
const getTabTooltip = (tab: { value: AlertTypes }): ReactNode => {
|
||||
const isAnomalyTab = tab.value === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const description = isAnomalyTab
|
||||
? ANOMALY_TAB_TOOLTIP
|
||||
: THRESHOLD_TAB_TOOLTIP;
|
||||
if (tab.value === alertType) {
|
||||
return description;
|
||||
const getTabTooltip = (tab: { value: AlertTypes }): string => {
|
||||
if (tab.value === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
return ANOMALY_TAB_TOOLTIP;
|
||||
}
|
||||
const switchNote = isAnomalyTab
|
||||
? ANOMALY_TAB_SWITCH_NOTE
|
||||
: THRESHOLD_TAB_SWITCH_NOTE;
|
||||
return (
|
||||
<>
|
||||
<div>{description}</div>
|
||||
<div className={styles.switchNote}>{switchNote}</div>
|
||||
</>
|
||||
);
|
||||
return THRESHOLD_TAB_TOOLTIP;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,11 +65,7 @@ function AlertCondition(): JSX.Element {
|
||||
<div className="alert-condition">
|
||||
<div className="alert-condition-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<Tooltip
|
||||
key={tab.value}
|
||||
title={getTabTooltip(tab)}
|
||||
overlayClassName={styles.tabTooltip}
|
||||
>
|
||||
<Tooltip key={tab.value} title={getTabTooltip(tab)}>
|
||||
<Button
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': alertType === tab.value,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import { Button, Select, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_INFO_THRESHOLD,
|
||||
INITIAL_RANDOM_THRESHOLD,
|
||||
INITIAL_WARNING_THRESHOLD,
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
THRESHOLD_OPERATOR_OPTIONS,
|
||||
} from '../context/constants';
|
||||
@@ -19,8 +23,7 @@ import {
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { normalizeMatchType, normalizeOperator } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { AnomalyAndThresholdProps } from './types';
|
||||
import { useThresholdList } from './useThresholdList';
|
||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
@@ -68,7 +71,42 @@ function AlertThreshold({
|
||||
selectedCategory || '',
|
||||
);
|
||||
|
||||
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
|
||||
const addThreshold = (): void => {
|
||||
let newThreshold;
|
||||
if (thresholdState.thresholds.length === 1) {
|
||||
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
|
||||
} else if (thresholdState.thresholds.length === 2) {
|
||||
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
|
||||
} else {
|
||||
newThreshold = {
|
||||
...INITIAL_RANDOM_THRESHOLD,
|
||||
id: v4(),
|
||||
color: getRandomColor(),
|
||||
};
|
||||
}
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [...thresholdState.thresholds, newThreshold],
|
||||
});
|
||||
};
|
||||
|
||||
const removeThreshold = (id: string): void => {
|
||||
if (thresholdState.thresholds.length > 1) {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.filter((t) => t.id !== id),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateThreshold: UpdateThreshold = (id, field, value) => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.map((t) =>
|
||||
t.id === id ? { ...t, [field]: value } : t,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const onTooltipOpenChange = (open: boolean): void => {
|
||||
// Stop propagation of click events on tooltip text to dropdown
|
||||
@@ -256,12 +294,11 @@ function AlertThreshold({
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => addThreshold()}
|
||||
testId="add-threshold-button"
|
||||
type="dashed"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addThreshold}
|
||||
className="add-threshold-btn"
|
||||
data-testid="add-threshold-button"
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
.querySelect {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.algorithmSelect,
|
||||
.seasonalitySelect {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.operatorSelect,
|
||||
.matchTypeSelect {
|
||||
width: 180px;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
@@ -10,20 +10,19 @@ import {
|
||||
ANOMALY_SEASONALITY_OPTIONS,
|
||||
ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
|
||||
INITIAL_ANOMALY_THRESHOLD,
|
||||
ANOMALY_TIME_DURATION_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from '../context/types';
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import { normalizeMatchType, normalizeOperator } from '../utils';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { AnomalyAndThresholdProps } from './types';
|
||||
import { useThresholdList } from './useThresholdList';
|
||||
import { getQueryNames, RoutingPolicyBanner } from './utils';
|
||||
|
||||
import styles from './AnomalyThreshold.module.scss';
|
||||
import {
|
||||
getQueryNames,
|
||||
NotificationChannelsNotFoundContent,
|
||||
RoutingPolicyBanner,
|
||||
} from './utils';
|
||||
|
||||
function AnomalyThreshold({
|
||||
channels,
|
||||
@@ -31,6 +30,7 @@ function AnomalyThreshold({
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
}: AnomalyAndThresholdProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const {
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
@@ -42,13 +42,23 @@ function AnomalyThreshold({
|
||||
|
||||
const queryNames = getQueryNames(currentQuery);
|
||||
|
||||
const { addThreshold, removeThreshold, updateThreshold } = useThresholdList();
|
||||
const deviationOptions = useMemo(() => {
|
||||
const options = [];
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
options.push({ label: i.toString(), value: i });
|
||||
}
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const handleAddThreshold = (): void => {
|
||||
addThreshold({
|
||||
thresholdValue: Math.max(
|
||||
1,
|
||||
INITIAL_ANOMALY_THRESHOLD.thresholdValue - thresholdState.thresholds.length,
|
||||
const updateThreshold = (
|
||||
id: string,
|
||||
field: string,
|
||||
value: string | string[],
|
||||
): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholdState.thresholds.map((t) =>
|
||||
t.id === id ? { ...t, [field]: value } : t,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -59,12 +69,11 @@ function AnomalyThreshold({
|
||||
{/* Sentence 1 */}
|
||||
<div className="alert-condition-sentence">
|
||||
<Typography.Text data-testid="notification-text" className="sentence-text">
|
||||
Send a notification when
|
||||
Send notification when the observed value for
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.selectedQuery}
|
||||
data-testid="query-select"
|
||||
className={styles.querySelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
@@ -73,16 +82,50 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={queryNames}
|
||||
/>
|
||||
<Typography.Text
|
||||
data-testid="evaluation-window-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
during the last
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.evaluationWindow}
|
||||
data-testid="evaluation-window-select"
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_EVALUATION_WINDOW',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
options={ANOMALY_TIME_DURATION_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="alert-condition-sentence">
|
||||
{/* Sentence 2 */}
|
||||
<Typography.Text data-testid="threshold-text" className="sentence-text">
|
||||
is
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].thresholdValue}
|
||||
data-testid="threshold-value-select"
|
||||
onChange={(value): void => {
|
||||
updateThreshold(
|
||||
thresholdState.thresholds[0].id,
|
||||
'thresholdValue',
|
||||
value.toString(),
|
||||
);
|
||||
}}
|
||||
options={deviationOptions}
|
||||
/>
|
||||
<Typography.Text data-testid="deviations-text" className="sentence-text">
|
||||
deviations
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={
|
||||
(normalizeOperator(thresholdState.operator) ??
|
||||
thresholdState.operator) as AlertThresholdOperator
|
||||
}
|
||||
data-testid="operator-select"
|
||||
className={styles.operatorSelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_OPERATOR',
|
||||
@@ -95,7 +138,7 @@ function AnomalyThreshold({
|
||||
data-testid="predicted-data-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
the prediction
|
||||
the predicted data
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={
|
||||
@@ -103,7 +146,6 @@ function AnomalyThreshold({
|
||||
thresholdState.matchType) as AlertThresholdMatchType
|
||||
}
|
||||
data-testid="match-type-select"
|
||||
className={styles.matchTypeSelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
@@ -112,22 +154,15 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS}
|
||||
/>
|
||||
<Typography.Text
|
||||
data-testid="evaluation-window-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
during the <EvaluationSettings rollingOnly />
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/* Sentence 3 */}
|
||||
<div className="alert-condition-sentence">
|
||||
{/* Sentence 2 */}
|
||||
<Typography.Text data-testid="using-the-text" className="sentence-text">
|
||||
using the
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.algorithm}
|
||||
data-testid="algorithm-select"
|
||||
className={styles.algorithmSelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_ALGORITHM',
|
||||
@@ -145,7 +180,6 @@ function AnomalyThreshold({
|
||||
<Select
|
||||
value={thresholdState.seasonality}
|
||||
data-testid="seasonality-select"
|
||||
className={styles.seasonalitySelect}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_SEASONALITY',
|
||||
@@ -154,39 +188,52 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_SEASONALITY_OPTIONS}
|
||||
/>
|
||||
<Typography.Text data-testid="seasonality-text" className="sentence-text">
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
{notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
className="sentence-text"
|
||||
>
|
||||
seasonality to
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.thresholds[0].channels}
|
||||
onChange={(value): void =>
|
||||
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
|
||||
}
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.id,
|
||||
label: channel.name,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
showSearch
|
||||
maxTagCount={2}
|
||||
maxTagPlaceholder={(omittedValues): string =>
|
||||
`+${omittedValues.length} more`
|
||||
}
|
||||
maxTagTextLength={10}
|
||||
filterOption={(input, option): boolean =>
|
||||
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
|
||||
}
|
||||
status={isErrorChannels ? 'error' : undefined}
|
||||
disabled={isLoadingChannels}
|
||||
notFoundContent={
|
||||
<NotificationChannelsNotFoundContent
|
||||
user={user}
|
||||
refreshChannels={refreshChannels}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text data-testid="seasonality-text" className="sentence-text">
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Sentence 3 */}
|
||||
<div className="thresholds-section">
|
||||
{thresholdState.thresholds.map((threshold, index) => (
|
||||
<ThresholdItem
|
||||
key={threshold.id}
|
||||
threshold={threshold}
|
||||
updateThreshold={updateThreshold}
|
||||
removeThreshold={removeThreshold}
|
||||
showRemoveButton={index !== 0 && thresholdState.thresholds.length > 1}
|
||||
channels={channels}
|
||||
isLoadingChannels={isLoadingChannels}
|
||||
units={[]}
|
||||
isErrorChannels={isErrorChannels}
|
||||
refreshChannels={refreshChannels}
|
||||
isAnomaly
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={handleAddThreshold}
|
||||
testId="add-threshold-button"
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
</div>
|
||||
<RoutingPolicyBanner
|
||||
notificationSettings={notificationSettings}
|
||||
setNotificationSettings={setNotificationSettings}
|
||||
|
||||
@@ -21,7 +21,6 @@ function ThresholdItem({
|
||||
isErrorChannels,
|
||||
refreshChannels,
|
||||
isLoadingChannels,
|
||||
isAnomaly = false,
|
||||
}: ThresholdItemProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { thresholdState, notificationSettings } = useCreateAlertState();
|
||||
@@ -105,16 +104,10 @@ function ThresholdItem({
|
||||
style={{ width: 200 }}
|
||||
data-testid="threshold-name-input"
|
||||
/>
|
||||
{isAnomaly ? (
|
||||
<Typography.Text className="sentence-text">at</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
{getOperatorSymbol()}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder="Enter threshold value"
|
||||
value={threshold.thresholdValue}
|
||||
@@ -125,11 +118,7 @@ function ThresholdItem({
|
||||
type="number"
|
||||
data-testid="threshold-value-input"
|
||||
/>
|
||||
{isAnomaly ? (
|
||||
<Typography.Text className="sentence-text">deviations</Typography.Text>
|
||||
) : (
|
||||
yAxisUnitSelect
|
||||
)}
|
||||
{yAxisUnitSelect}
|
||||
{!notificationSettings.routingPolicies && (
|
||||
<>
|
||||
<Typography.Text className="sentence-text">send to</Typography.Text>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IAppContext } from 'providers/App/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import AlertCondition from '../AlertCondition';
|
||||
@@ -65,55 +65,54 @@ jest.mock('../AnomalyThreshold', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHandleSetQueryData: jest.MockedFunction<
|
||||
QueryBuilderContextType['handleSetQueryData']
|
||||
> = jest.fn();
|
||||
const mockRedirectWithQueryBuilderData: jest.MockedFunction<
|
||||
QueryBuilderContextType['redirectWithQueryBuilderData']
|
||||
> = jest.fn();
|
||||
const mockCurrentQuery = {
|
||||
dataSource: 'METRICS',
|
||||
queryName: 'A',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'METRICS' }],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: mockCurrentQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
useQueryBuilder: (): {
|
||||
currentQuery: {
|
||||
builder: { queryData: unknown[]; queryFormulas: unknown[] };
|
||||
dataSource: string;
|
||||
queryName: string;
|
||||
};
|
||||
redirectWithQueryBuilderData: () => void;
|
||||
} => ({
|
||||
currentQuery: {
|
||||
dataSource: 'METRICS',
|
||||
queryName: 'A',
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'METRICS' }],
|
||||
queryFormulas: [],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderAlertCondition = (
|
||||
alertType?: string,
|
||||
appContextOverrides?: Partial<IAppContext>,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: alertType ? `/?alertType=${alertType}` : '/',
|
||||
appContextOverrides,
|
||||
},
|
||||
): ReturnType<typeof render> => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const initialEntries = alertType ? [`/?alertType=${alertType}`] : undefined;
|
||||
return render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<AlertCondition />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const clickText = async (text: string): Promise<void> => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByText(text));
|
||||
};
|
||||
|
||||
describe('AlertCondition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the stepper with correct step number and label', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
|
||||
@@ -126,9 +125,10 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -136,7 +136,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold tab by default', () => {
|
||||
@@ -151,7 +152,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
renderAlertCondition();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
@@ -165,47 +167,36 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns in the tab tooltip that switching changes what gets saved', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAlertCondition();
|
||||
|
||||
await user.hover(screen.getByText(ANOMALY_TAB_TEXT));
|
||||
await expect(
|
||||
screen.findByText(/saves this as an anomaly-based alert/),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText(ANOMALY_TAB_TEXT));
|
||||
await user.hover(screen.getByText(THRESHOLD_TAB_TEXT));
|
||||
await expect(
|
||||
screen.findByText(/saves this as a metrics-based alert/),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AnomalyThreshold component when alert type is anomaly based', async () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Click on anomaly tab to switch to anomaly-based alert
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
|
||||
fireEvent.click(anomalyTab);
|
||||
|
||||
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches between threshold and anomaly tabs', async () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
// Click anomaly tab
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
const anomalyTab = screen.getByText(ANOMALY_TAB_TEXT);
|
||||
fireEvent.click(anomalyTab);
|
||||
|
||||
// Should show anomaly component
|
||||
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
// Click threshold tab
|
||||
await clickText(THRESHOLD_TAB_TEXT);
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
fireEvent.click(thresholdTab);
|
||||
|
||||
// Should show threshold component again
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
@@ -214,40 +205,8 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds the anomaly function with the default z-score when switching to anomaly', async () => {
|
||||
renderAlertCondition();
|
||||
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
|
||||
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
functions: [
|
||||
{
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: 3, seasonality: 'hourly' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the current query when switching between threshold and anomaly', async () => {
|
||||
renderAlertCondition();
|
||||
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
|
||||
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
|
||||
mockCurrentQuery,
|
||||
{ alertType: AlertTypes.ANOMALY_BASED_ALERT, ruleType: 'anomaly_rule' },
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('applies active tab styling correctly', async () => {
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -258,7 +217,7 @@ describe('AlertCondition', () => {
|
||||
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).not.toBeInTheDocument();
|
||||
|
||||
// Click anomaly tab
|
||||
await clickText(ANOMALY_TAB_TEXT);
|
||||
fireEvent.click(anomalyTab);
|
||||
|
||||
// Anomaly tab should be active now
|
||||
expect(anomalyTab.closest(ACTIVE_TAB_CLASS)).toBeInTheDocument();
|
||||
@@ -268,17 +227,11 @@ describe('AlertCondition', () => {
|
||||
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT');
|
||||
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the anomaly tab for METRICS_BASED_ALERT on the community edition', () => {
|
||||
renderAlertCondition('METRIC_BASED_ALERT', { activeLicense: null });
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
|
||||
@@ -286,8 +239,9 @@ describe('AlertCondition', () => {
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
|
||||
import * as context from '../../context';
|
||||
import { AlertThresholdMatchType } from '../../context/types';
|
||||
import AnomalyThreshold from '../AnomalyThreshold';
|
||||
|
||||
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
@@ -20,11 +22,12 @@ jest.mock('uplot', () => {
|
||||
|
||||
const mockSetAlertState = jest.fn();
|
||||
const mockSetThresholdState = jest.fn();
|
||||
const mockContextState = createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
});
|
||||
const useCreateAlertStateSpy = jest.spyOn(context, 'useCreateAlertState');
|
||||
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setThresholdState: mockSetThresholdState,
|
||||
setAlertState: mockSetAlertState,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock useQueryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
@@ -49,12 +52,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const channels: ChannelOption[] = [{ id: '1', name: 'slack-alerts' }];
|
||||
|
||||
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<AnomalyThreshold
|
||||
channels={channels}
|
||||
channels={[]}
|
||||
isLoadingChannels={false}
|
||||
isErrorChannels={false}
|
||||
refreshChannels={jest.fn()}
|
||||
@@ -64,117 +65,31 @@ const renderAnomalyThreshold = (): ReturnType<typeof render> =>
|
||||
describe('AnomalyThreshold', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useCreateAlertStateSpy.mockReturnValue(mockContextState);
|
||||
});
|
||||
|
||||
const pickOption = async (testId: string, title: string): Promise<void> => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(within(screen.getByTestId(testId)).getByRole('combobox'));
|
||||
await user.click(await screen.findByTitle(title));
|
||||
};
|
||||
|
||||
it('renders the condition sentence in the same order as threshold alerts', () => {
|
||||
it('renders the first condition sentence', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('notification-text')).toHaveTextContent(
|
||||
'Send a notification when',
|
||||
);
|
||||
expect(screen.getByTestId('query-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('evaluation-window-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('evaluation-window-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the second condition sentence', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('threshold-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-value-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('deviations-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('operator-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('predicted-data-text')).toHaveTextContent(
|
||||
'the prediction',
|
||||
);
|
||||
expect(screen.getByTestId('predicted-data-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('match-type-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('evaluation-settings-button')).toHaveTextContent(
|
||||
'Rolling',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the algorithm and seasonality sentence', () => {
|
||||
it('renders the third condition sentence', () => {
|
||||
renderAnomalyThreshold();
|
||||
expect(screen.getByTestId('using-the-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('algorithm-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('algorithm-with-text')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-text')).toHaveTextContent(
|
||||
'seasonality',
|
||||
);
|
||||
});
|
||||
|
||||
it('renames the severity from its threshold row', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAnomalyThreshold();
|
||||
await user.type(screen.getByTestId('threshold-name-input'), 'x');
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ label: 'criticalx' })],
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a typed deviation', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
useCreateAlertStateSpy.mockReturnValue({
|
||||
...mockContextState,
|
||||
thresholdState: {
|
||||
...mockContextState.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...mockContextState.thresholdState.thresholds[0],
|
||||
thresholdValue: '' as unknown as number,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
renderAnomalyThreshold();
|
||||
await user.type(screen.getByTestId('threshold-value-input'), '4');
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ thresholdValue: '4' })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ON AVERAGE', AlertThresholdMatchType.ON_AVERAGE],
|
||||
['LAST', AlertThresholdMatchType.LAST],
|
||||
])('offers the %s match type', async (title, matchType) => {
|
||||
renderAnomalyThreshold();
|
||||
await pickOption('match-type-select', title);
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: matchType,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks channels by name when routing policies are off', async () => {
|
||||
renderAnomalyThreshold();
|
||||
await pickOption('threshold-notification-channel-select', 'slack-alerts');
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [expect.objectContaining({ channels: ['slack-alerts'] })],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the next severity one deviation lower', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderAnomalyThreshold();
|
||||
await user.click(screen.getByTestId('add-threshold-button'));
|
||||
expect(mockSetThresholdState).toHaveBeenCalledWith({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [
|
||||
mockContextState.thresholdState.thresholds[0],
|
||||
expect.objectContaining({ label: 'warning', thresholdValue: 2 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the channel picker when routing policies are on', () => {
|
||||
useCreateAlertStateSpy.mockReturnValue({
|
||||
...mockContextState,
|
||||
notificationSettings: {
|
||||
...mockContextState.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
});
|
||||
renderAnomalyThreshold();
|
||||
expect(
|
||||
screen.queryByTestId('threshold-notification-channel-select'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,14 +149,6 @@ describe('ThresholdItem', () => {
|
||||
expect(valueInput).toHaveValue(100);
|
||||
});
|
||||
|
||||
it('asks for deviations instead of a unit for anomaly alerts', () => {
|
||||
renderThresholdItem({ isAnomaly: true });
|
||||
|
||||
expect(screen.getByText('deviations')).toBeInTheDocument();
|
||||
expect(screen.queryByText('on value')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-unit-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders unit selector with correct value', () => {
|
||||
renderThresholdItem();
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
|
||||
import { INITIAL_CRITICAL_THRESHOLD } from '../../context/constants';
|
||||
import { Threshold } from '../../context/types';
|
||||
|
||||
import { getSmallestDeviation, syncAnomalyFunction } from '../utils';
|
||||
|
||||
const timeShift: QueryFunction = { name: 'timeShift', args: [{ value: 3600 }] };
|
||||
const anomaly = (
|
||||
zScoreThreshold: number,
|
||||
seasonality: string,
|
||||
): QueryFunction => ({
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: zScoreThreshold, seasonality },
|
||||
});
|
||||
|
||||
describe('syncAnomalyFunction', () => {
|
||||
it('adds the anomaly function after the existing functions', () => {
|
||||
expect(
|
||||
syncAnomalyFunction([timeShift], {
|
||||
z_score_threshold: 3,
|
||||
seasonality: 'daily',
|
||||
}),
|
||||
).toStrictEqual([timeShift, anomaly(3, 'daily')]);
|
||||
});
|
||||
|
||||
it('replaces stale args and gives the same result when run again', () => {
|
||||
const namedArgs = { z_score_threshold: 5, seasonality: 'weekly' };
|
||||
const synced = syncAnomalyFunction(
|
||||
[anomaly(3, 'hourly'), timeShift],
|
||||
namedArgs,
|
||||
);
|
||||
expect(synced).toStrictEqual([timeShift, anomaly(5, 'weekly')]);
|
||||
expect(syncAnomalyFunction(synced, namedArgs)).toStrictEqual(synced);
|
||||
});
|
||||
|
||||
it('removes the anomaly function when no args are given', () => {
|
||||
expect(
|
||||
syncAnomalyFunction([anomaly(3, 'daily'), timeShift], undefined),
|
||||
).toStrictEqual([timeShift]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSmallestDeviation', () => {
|
||||
it.each([
|
||||
[[3, 2], 2],
|
||||
[['2.5', 4], 2.5],
|
||||
[[3, '', 0], 3],
|
||||
[[''], 0],
|
||||
])('picks the smallest valid deviation from %p', (values, expected) => {
|
||||
const thresholds = values.map(
|
||||
(thresholdValue) =>
|
||||
({ ...INITIAL_CRITICAL_THRESHOLD, thresholdValue }) as Threshold,
|
||||
);
|
||||
expect(getSmallestDeviation(thresholds)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,5 @@ export const THRESHOLD_TAB_TOOLTIP =
|
||||
export const ANOMALY_TAB_TOOLTIP =
|
||||
'An alert is triggered whenever the metric deviates from an expected pattern.';
|
||||
|
||||
export const THRESHOLD_TAB_SWITCH_NOTE =
|
||||
'Switching keeps your query but saves this as a metrics-based alert, not an anomaly alert.';
|
||||
|
||||
export const ANOMALY_TAB_SWITCH_NOTE =
|
||||
'Switching keeps your query but saves this as an anomaly-based alert. Anomaly detection works on Query Builder metric queries only.';
|
||||
|
||||
export const ROUTING_POLICIES_ROUTE =
|
||||
'/alerts?tab=Configuration&subTab=routing-policies';
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 240px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
@@ -253,6 +255,28 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-threshold-btn {
|
||||
margin-top: 8px;
|
||||
border: 1px dashed var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
background-color: transparent;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.routing-policies-info-banner {
|
||||
@@ -290,6 +314,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.anomaly-threshold-container {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.condensed-alert-threshold-container,
|
||||
.condensed-anomaly-threshold-container {
|
||||
width: 100%;
|
||||
|
||||
@@ -26,13 +26,6 @@ export interface ThresholdItemProps {
|
||||
units: DefaultOptionType[];
|
||||
isErrorChannels: boolean;
|
||||
refreshChannels: () => void;
|
||||
isAnomaly?: boolean;
|
||||
}
|
||||
|
||||
export interface ThresholdList {
|
||||
addThreshold: (overrides?: Partial<Threshold>) => void;
|
||||
removeThreshold: (thresholdId: string) => void;
|
||||
updateThreshold: UpdateThreshold;
|
||||
}
|
||||
|
||||
export interface AnomalyAndThresholdProps {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { getSmallestDeviation, syncAnomalyFunction } from './utils';
|
||||
|
||||
// These args only drive the preview; the rule reads the threshold target and condition.seasonality.
|
||||
export function useAnomalyFunctionSync(): void {
|
||||
const { alertType, thresholdState } = useCreateAlertState();
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const isAnomalyAlert = alertType === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const zScoreThreshold = getSmallestDeviation(thresholdState.thresholds);
|
||||
const { seasonality } = thresholdState;
|
||||
|
||||
useEffect(() => {
|
||||
const namedArgs = isAnomalyAlert
|
||||
? { z_score_threshold: zScoreThreshold, seasonality }
|
||||
: undefined;
|
||||
currentQuery.builder.queryData.forEach((queryData, index) => {
|
||||
const currentFunctions = queryData.functions || [];
|
||||
const functions = syncAnomalyFunction(currentFunctions, namedArgs);
|
||||
if (!isEqual(functions, currentFunctions)) {
|
||||
handleSetQueryData(index, { ...queryData, functions });
|
||||
}
|
||||
});
|
||||
}, [
|
||||
currentQuery.builder.queryData,
|
||||
isAnomalyAlert,
|
||||
zScoreThreshold,
|
||||
seasonality,
|
||||
handleSetQueryData,
|
||||
]);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import getRandomColor from 'lib/getRandomColor';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
INITIAL_INFO_THRESHOLD,
|
||||
INITIAL_RANDOM_THRESHOLD,
|
||||
INITIAL_WARNING_THRESHOLD,
|
||||
} from '../context/constants';
|
||||
import { Threshold } from '../context/types';
|
||||
import { ThresholdList, UpdateThreshold } from './types';
|
||||
|
||||
export function useThresholdList(): ThresholdList {
|
||||
const { thresholdState, setThresholdState } = useCreateAlertState();
|
||||
const { thresholds } = thresholdState;
|
||||
|
||||
const addThreshold = (overrides?: Partial<Threshold>): void => {
|
||||
let newThreshold;
|
||||
if (thresholds.length === 1) {
|
||||
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
|
||||
} else if (thresholds.length === 2) {
|
||||
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
|
||||
} else {
|
||||
newThreshold = {
|
||||
...INITIAL_RANDOM_THRESHOLD,
|
||||
id: v4(),
|
||||
color: getRandomColor(),
|
||||
};
|
||||
}
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [...thresholds, { ...newThreshold, ...overrides }],
|
||||
});
|
||||
};
|
||||
|
||||
const removeThreshold = (id: string): void => {
|
||||
if (thresholds.length > 1) {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds.filter((t) => t.id !== id),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateThreshold: UpdateThreshold = (id, field, value) => {
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
||||
});
|
||||
};
|
||||
|
||||
return { addThreshold, removeThreshold, updateThreshold };
|
||||
}
|
||||
@@ -15,13 +15,11 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { ROUTING_POLICIES_ROUTE } from './constants';
|
||||
import { Threshold } from '../context/types';
|
||||
import { RoutingPolicyBannerProps } from './types';
|
||||
|
||||
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
@@ -45,24 +43,6 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
return queryConfig[currentQuery.queryType]?.() || [];
|
||||
}
|
||||
|
||||
export function getSmallestDeviation(thresholds: Threshold[]): number {
|
||||
const deviations = thresholds
|
||||
.map(({ thresholdValue }) => Number(thresholdValue))
|
||||
.filter((deviation) => deviation > 0);
|
||||
return deviations.length > 0 ? Math.min(...deviations) : 0;
|
||||
}
|
||||
|
||||
export function syncAnomalyFunction(
|
||||
functions: QueryFunction[],
|
||||
namedArgs: QueryFunction['namedArgs'],
|
||||
): QueryFunction[] {
|
||||
const otherFunctions = functions.filter(({ name }) => name !== 'anomaly');
|
||||
if (!namedArgs) {
|
||||
return otherFunctions;
|
||||
}
|
||||
return [...otherFunctions, { name: 'anomaly', args: [], namedArgs }];
|
||||
}
|
||||
|
||||
export function getCategoryByOptionId(id: string): string | undefined {
|
||||
const categories = getYAxisCategories(YAxisSource.ALERTS);
|
||||
return categories.find((category) =>
|
||||
@@ -243,22 +223,17 @@ function TooltipExample({
|
||||
thresholdValue: number;
|
||||
matchType: AlertThresholdMatchType;
|
||||
}): JSX.Element {
|
||||
const pointsLabel =
|
||||
matchType === AlertThresholdMatchType.IN_TOTAL
|
||||
? 'error counts'
|
||||
: 'data points';
|
||||
return (
|
||||
<div className="tooltip-example">
|
||||
<strong>Example:</strong>
|
||||
<br />
|
||||
<span className="translate-safe">
|
||||
{`Say, For a 5-minute window (configured in Evaluation settings), 1 min aggregation interval (set up in query) → 5 ${pointsLabel}: [${dataPoints.join(', ')}]`}
|
||||
</span>
|
||||
<br />
|
||||
<span className="translate-safe">
|
||||
{`With threshold ${operatorSymbol} ${thresholdValue}: `}
|
||||
</span>
|
||||
{children}
|
||||
Say, For a 5-minute window (configured in Evaluation settings), 1 min
|
||||
aggregation interval (set up in query) → 5{' '}
|
||||
{matchType === AlertThresholdMatchType.IN_TOTAL
|
||||
? 'error counts'
|
||||
: 'data points'}
|
||||
: [{dataPoints.join(', ')}]<br />
|
||||
With threshold {operatorSymbol} {thresholdValue}: {children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||
import { IEvaluationSettingsProps } from './types';
|
||||
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
function EvaluationSettings({
|
||||
rollingOnly = false,
|
||||
}: IEvaluationSettingsProps): JSX.Element {
|
||||
function EvaluationSettings(): JSX.Element {
|
||||
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
|
||||
|
||||
const [isEvaluationWindowPopoverOpen, setIsEvaluationWindowPopoverOpen] =
|
||||
@@ -27,7 +24,6 @@ function EvaluationSettings({
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={evaluationWindow}
|
||||
setEvaluationWindow={setEvaluationWindow}
|
||||
rollingOnly={rollingOnly}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
|
||||
@@ -20,12 +20,7 @@ import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNa
|
||||
function EvaluationWindowPopover({
|
||||
evaluationWindow,
|
||||
setEvaluationWindow,
|
||||
rollingOnly = false,
|
||||
}: IEvaluationWindowPopoverProps): JSX.Element {
|
||||
const windowTypeOptions = rollingOnly
|
||||
? EVALUATION_WINDOW_TYPE.filter((option) => option.value === 'rolling')
|
||||
: EVALUATION_WINDOW_TYPE;
|
||||
|
||||
const { containerRef, firstItemRef } =
|
||||
useKeyboardNavigationForEvaluationWindowPopover({
|
||||
onSelect: (value: string, sectionId: string): void => {
|
||||
@@ -141,7 +136,7 @@ function EvaluationWindowPopover({
|
||||
<div className="evaluation-window-content">
|
||||
{renderEvaluationWindowContent(
|
||||
'EVALUATION WINDOW',
|
||||
windowTypeOptions,
|
||||
EVALUATION_WINDOW_TYPE,
|
||||
evaluationWindow.windowType,
|
||||
(value: string): void =>
|
||||
setEvaluationWindow({
|
||||
|
||||
@@ -39,18 +39,6 @@ describe('EvaluationWindowPopover', () => {
|
||||
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only rolling windows when rollingOnly is set', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
evaluationWindow={mockEvaluationWindow}
|
||||
setEvaluationWindow={mockSetEvaluationWindow}
|
||||
rollingOnly
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Rolling')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Cumulative')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all window type options with rolling selected', () => {
|
||||
render(
|
||||
<EvaluationWindowPopover
|
||||
|
||||
@@ -31,14 +31,9 @@ export enum CumulativeWindowTimeframes {
|
||||
CURRENT_MONTH = 'currentMonth',
|
||||
}
|
||||
|
||||
export interface IEvaluationSettingsProps {
|
||||
rollingOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowPopoverProps {
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
rollingOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IEvaluationWindowDetailsProps {
|
||||
|
||||
@@ -13,7 +13,10 @@ import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
import {
|
||||
@@ -81,7 +84,7 @@ function Footer(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleTestNotification = useCallback((): void => {
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -114,12 +117,11 @@ function Footer(): JSX.Element {
|
||||
notificationSettings,
|
||||
currentQuery,
|
||||
testAlertRule,
|
||||
handleApiError,
|
||||
]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -171,7 +173,6 @@ function Footer(): JSX.Element {
|
||||
createAlertRule,
|
||||
safeNavigate,
|
||||
handleApiError,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const isSavingAlertRule = isCreatingAlertRule || isUpdatingAlertRule;
|
||||
|
||||
@@ -48,6 +48,7 @@ const mockAlertContextState = createMockAlertContextState({
|
||||
selectedQuery: 'A',
|
||||
operator: AlertThresholdOperator.ABOVE_BELOW,
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
evaluationWindow: '5m0s',
|
||||
algorithm: 'standard',
|
||||
seasonality: 'hourly',
|
||||
thresholds: [
|
||||
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -20,7 +18,6 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { BuildCreateAlertRulePayloadArgs } from '../types';
|
||||
import {
|
||||
buildCreateAlertRulePayload,
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
getAlertOnAbsentProps,
|
||||
getEnforceMinimumDatapointsProps,
|
||||
@@ -99,33 +96,6 @@ describe('Footer utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'Please enter a deviation greater than 0'],
|
||||
[0, 'Please enter a deviation greater than 0'],
|
||||
[-2, 'Please enter a deviation greater than 0'],
|
||||
['2.5', null],
|
||||
])('when the anomaly deviation is %p', (thresholdValue, expected) => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
basicAlertState: { ...args.basicAlertState, name: 'test name' },
|
||||
thresholdState: {
|
||||
...args.thresholdState,
|
||||
thresholds: [
|
||||
{
|
||||
...args.thresholdState.thresholds[0],
|
||||
thresholdValue: thresholdValue as number,
|
||||
},
|
||||
],
|
||||
},
|
||||
notificationSettings: {
|
||||
...args.notificationSettings,
|
||||
routingPolicies: true,
|
||||
},
|
||||
};
|
||||
expect(validateCreateAlertState(currentArgs)).toBe(expected);
|
||||
});
|
||||
|
||||
it('when threshold channels are not provided but routing policies are enabled', () => {
|
||||
const currentArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
...args,
|
||||
@@ -603,93 +573,4 @@ describe('Footer utils', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildCreateAlertRulePayload', () => {
|
||||
const mockCreateAlertContextState = createMockAlertContextState();
|
||||
const thresholdState = mockCreateAlertContextState.thresholdState;
|
||||
const anomalyArgs: BuildCreateAlertRulePayloadArgs = {
|
||||
basicAlertState: mockCreateAlertContextState.alertState,
|
||||
thresholdState: {
|
||||
...thresholdState,
|
||||
seasonality: Seasonality.DAILY,
|
||||
thresholds: [
|
||||
{ ...thresholdState.thresholds[0], thresholdValue: 3, unit: 'ms' },
|
||||
],
|
||||
},
|
||||
advancedOptions: mockCreateAlertContextState.advancedOptions,
|
||||
evaluationWindow: {
|
||||
...mockCreateAlertContextState.evaluationWindow,
|
||||
windowType: 'rolling',
|
||||
timeframe: '1h0m0s',
|
||||
},
|
||||
notificationSettings: mockCreateAlertContextState.notificationSettings,
|
||||
query: initialQueriesMap.metrics,
|
||||
alertType: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
};
|
||||
|
||||
it('builds a v2alpha1 anomaly rule with a rolling window', () => {
|
||||
const props = buildCreateAlertRulePayload(anomalyArgs);
|
||||
expect(props.ruleType).toBe('anomaly_rule');
|
||||
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
|
||||
expect(props.schemaVersion).toBe('v2alpha1');
|
||||
expect(props.condition.algorithm).toBe('standard');
|
||||
expect(props.condition.seasonality).toBe('daily');
|
||||
expect(props.condition.thresholds?.spec[0]).toMatchObject({
|
||||
target: 3,
|
||||
targetUnit: '',
|
||||
});
|
||||
expect(props.evaluation).toStrictEqual({
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[AlertThresholdOperator.IS_BELOW, -3],
|
||||
['2', -3],
|
||||
[AlertThresholdOperator.IS_ABOVE, 3],
|
||||
[AlertThresholdOperator.ABOVE_BELOW, 3],
|
||||
])('signs the z-score target for operator %s', (operator, target) => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
thresholdState: { ...anomalyArgs.thresholdState, operator },
|
||||
});
|
||||
expect(props.condition.thresholds?.spec[0].target).toBe(target);
|
||||
expect(props.condition.thresholds?.spec[0].op).toBe(operator);
|
||||
});
|
||||
|
||||
it('signs every anomaly severity the same way', () => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
thresholdState: {
|
||||
...anomalyArgs.thresholdState,
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [
|
||||
{ ...anomalyArgs.thresholdState.thresholds[0], thresholdValue: 3 },
|
||||
{
|
||||
...anomalyArgs.thresholdState.thresholds[0],
|
||||
id: 'warning',
|
||||
label: 'warning',
|
||||
thresholdValue: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
props.condition.thresholds?.spec.map(({ name, target }) => [name, target]),
|
||||
).toStrictEqual([
|
||||
['critical', -3],
|
||||
['warning', -2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds a threshold rule for other alert types', () => {
|
||||
const props = buildCreateAlertRulePayload({
|
||||
...anomalyArgs,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
});
|
||||
expect(props.ruleType).toBe('threshold_rule');
|
||||
expect(props.condition.seasonality).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
BasicThreshold,
|
||||
PostableAlertRuleV2,
|
||||
@@ -10,10 +9,8 @@ import {
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
|
||||
|
||||
import { normalizeOperator } from '../context/conditionNormalizers';
|
||||
import {
|
||||
AdvancedOptionsState,
|
||||
AlertThresholdOperator,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
} from '../context/types';
|
||||
@@ -34,8 +31,7 @@ export function getFormattedTimeValue(timeValue: number, unit: string): string {
|
||||
export function validateCreateAlertState(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): string | null {
|
||||
const { alertType, basicAlertState, thresholdState, notificationSettings } =
|
||||
args;
|
||||
const { basicAlertState, thresholdState, notificationSettings } = args;
|
||||
|
||||
// Validate alert name
|
||||
if (!basicAlertState.name) {
|
||||
@@ -48,12 +44,6 @@ export function validateCreateAlertState(
|
||||
if (!threshold.label) {
|
||||
return 'Please enter a label for each threshold';
|
||||
}
|
||||
if (
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
!(Number(threshold.thresholdValue) > 0)
|
||||
) {
|
||||
return 'Please enter a deviation greater than 0';
|
||||
}
|
||||
// this runs during render, so a throw here takes down the whole page
|
||||
if (!notificationSettings.routingPolicies && !threshold.channels?.length) {
|
||||
return 'Please select at least one channel for each threshold or enable routing policies';
|
||||
@@ -299,43 +289,56 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
}
|
||||
|
||||
// Build Create Anomaly Alert Rule Payload
|
||||
// TODO: Update this function before enabling anomaly alert rule creation
|
||||
export function buildCreateAnomalyAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
const { thresholdState } = args;
|
||||
const payload = buildCreateThresholdAlertRulePayload(args);
|
||||
const {
|
||||
alertType,
|
||||
basicAlertState,
|
||||
query,
|
||||
notificationSettings,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
} = args;
|
||||
|
||||
// Targets are literal z-scores: "below N deviations" fires under -N.
|
||||
// A target unit would rescale the z-score, so it is always cleared.
|
||||
const sign =
|
||||
normalizeOperator(thresholdState.operator) === AlertThresholdOperator.IS_BELOW
|
||||
? -1
|
||||
: 1;
|
||||
const thresholds: BasicThreshold[] = (
|
||||
payload.condition.thresholds?.spec || []
|
||||
).map((threshold) => ({
|
||||
...threshold,
|
||||
target: sign * Math.abs(threshold.target),
|
||||
targetUnit: '',
|
||||
}));
|
||||
const compositeQuery = compositeQueryToQueryEnvelope({
|
||||
builderQueries: {
|
||||
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
|
||||
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
|
||||
},
|
||||
promQueries: mapQueryDataToApi(query.promql, 'name').data,
|
||||
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
|
||||
queryType: query.queryType,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
unit: basicAlertState.yAxisUnit,
|
||||
});
|
||||
|
||||
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
|
||||
const enforceMinimumDatapointsProps =
|
||||
getEnforceMinimumDatapointsProps(advancedOptions);
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
const notificationSettingsProps =
|
||||
getNotificationSettingsProps(notificationSettings);
|
||||
|
||||
return {
|
||||
...payload,
|
||||
alert: basicAlertState.name,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
alertType,
|
||||
condition: {
|
||||
...payload.condition,
|
||||
thresholds: { kind: 'basic', spec: thresholds },
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
compositeQuery,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
},
|
||||
labels: basicAlertState.labels,
|
||||
annotations: {
|
||||
description: notificationSettings.description,
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAlertRulePayload(
|
||||
args: BuildCreateAlertRulePayloadArgs,
|
||||
): PostableAlertRuleV2 {
|
||||
return args.alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? buildCreateAnomalyAlertRulePayload(args)
|
||||
: buildCreateThresholdAlertRulePayload(args);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
@@ -74,23 +74,20 @@ function QuerySection(): JSX.Element {
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
const handleFetchingStateChange = useCallback((isFetching: boolean): void => {
|
||||
setIsLoadingQueries(isFetching);
|
||||
if (isFetching) {
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, []);
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
void queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
setIsCancelled(true);
|
||||
}, [queryClient]);
|
||||
|
||||
const runQueryHandler = useCallback(() => {
|
||||
setIsCancelled(false);
|
||||
void queryClient.invalidateQueries([
|
||||
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
|
||||
]);
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
|
||||
// Reset the source param when the query is changed
|
||||
// Then manually run the query
|
||||
if (source === YAxisSource.DASHBOARDS && didQueryChange) {
|
||||
@@ -132,11 +129,6 @@ function QuerySection(): JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
const isAnomalyDetection = alertType === AlertTypes.ANOMALY_BASED_ALERT;
|
||||
const activeTab = isAnomalyDetection
|
||||
? AlertTypes.METRICS_BASED_ALERT
|
||||
: alertType;
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<Stepper stepNumber={1} label="Define the query" />
|
||||
@@ -144,7 +136,7 @@ function QuerySection(): JSX.Element {
|
||||
alertDef={alertDef}
|
||||
source={source}
|
||||
isCancelled={isCancelled}
|
||||
onFetchingStateChange={handleFetchingStateChange}
|
||||
onFetchingStateChange={setIsLoadingQueries}
|
||||
/>
|
||||
<div className="query-section-tabs">
|
||||
<div className="query-section-query-actions">
|
||||
@@ -152,12 +144,9 @@ function QuerySection(): JSX.Element {
|
||||
<Button
|
||||
key={tab.value}
|
||||
className={classNames('list-view-tab', 'explorer-view-option', {
|
||||
'active-tab': activeTab === tab.value,
|
||||
'active-tab': alertType === tab.value,
|
||||
})}
|
||||
onClick={(): void => {
|
||||
if (isAnomalyDetection && tab.value === activeTab) {
|
||||
return;
|
||||
}
|
||||
setAlertType(tab.value as AlertTypes);
|
||||
}}
|
||||
>
|
||||
@@ -176,7 +165,6 @@ function QuerySection(): JSX.Element {
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isAnomalyDetection={isAnomalyDetection}
|
||||
key={currentQuery.queryType}
|
||||
ruleId=""
|
||||
hideTitle
|
||||
|
||||
@@ -61,12 +61,10 @@ jest.mock(
|
||||
alertType,
|
||||
panelType,
|
||||
setQueryCategory,
|
||||
isAnomalyDetection,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<div data-testid="query-section-component">
|
||||
<div data-testid="query-category">{queryCategory}</div>
|
||||
<div data-testid="is-anomaly-detection">{String(isAnomalyDetection)}</div>
|
||||
<div data-testid="alert-type">{alertType}</div>
|
||||
<div data-testid="panel-type">{panelType}</div>
|
||||
<button
|
||||
@@ -129,11 +127,11 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
},
|
||||
});
|
||||
const renderQuerySection = (search = ''): ReturnType<typeof render> =>
|
||||
const renderQuerySection = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<MemoryRouter>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<QuerySection />
|
||||
</CreateAlertProvider>
|
||||
@@ -203,27 +201,6 @@ describe('QuerySection', () => {
|
||||
expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS);
|
||||
});
|
||||
|
||||
it('keeps Metrics active and restricts query types for anomaly alerts', () => {
|
||||
renderQuerySection(`?${QueryParams.ruleType}=anomaly_rule`);
|
||||
|
||||
expect(screen.getByText(METRICS_TEXT).closest('button')).toHaveClass(
|
||||
ACTIVE_TAB_CLASS,
|
||||
);
|
||||
expect(screen.getByTestId('is-anomaly-detection')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('stays on the anomaly alert when its active Metrics tab is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection(`?${QueryParams.ruleType}=anomaly_rule`);
|
||||
|
||||
await user.click(screen.getByText(METRICS_TEXT));
|
||||
|
||||
expect(
|
||||
mockUseQueryBuilder.redirectWithQueryBuilderData,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('is-anomaly-detection')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('handles alert type change when clicking on different tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderQuerySection();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import { defaultPostableAlertRuleV2 } from '../constants';
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import {
|
||||
getAdvancedOptionsStateFromAlertDef,
|
||||
getColorForThreshold,
|
||||
getAlertTypeFromAlertDef,
|
||||
getCreateAlertLocalStateFromAlertDef,
|
||||
getEvaluationWindowStateFromAlertDef,
|
||||
getNotificationSettingsStateFromAlertDef,
|
||||
@@ -273,7 +271,7 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('getThresholdStateFromAlertDef maps thresholds, query, operator and match type', () => {
|
||||
describe('getThresholdStateFromAlertDef', () => {
|
||||
const args: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
annotations: {
|
||||
@@ -318,81 +316,6 @@ describe('CreateAlertV2 utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
|
||||
const anomalyDef: PostableAlertRuleV2 = {
|
||||
...defaultPostableAlertRuleV2,
|
||||
ruleType: 'anomaly_rule',
|
||||
condition: {
|
||||
...defaultPostableAlertRuleV2.condition,
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target: -3,
|
||||
targetUnit: '',
|
||||
channels: ['email'],
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
op: AlertThresholdOperator.IS_BELOW,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '1h0m0s', frequency: '1m' },
|
||||
},
|
||||
};
|
||||
|
||||
it('shows deviations unsigned and restores the anomaly settings', () => {
|
||||
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
thresholds: [{ thresholdValue: 3 }],
|
||||
algorithm: 'standard',
|
||||
seasonality: 'weekly',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the anomaly window into the shared evaluation window', () => {
|
||||
expect(getEvaluationWindowStateFromAlertDef(anomalyDef)).toMatchObject({
|
||||
windowType: 'rolling',
|
||||
timeframe: '1h0m0s',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps signed targets for threshold rules', () => {
|
||||
const state = getThresholdStateFromAlertDef({
|
||||
...anomalyDef,
|
||||
ruleType: 'threshold_rule',
|
||||
});
|
||||
expect(state.thresholds[0].thresholdValue).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertTypeFromAlertDef', () => {
|
||||
it('maps anomaly rules to the anomaly alert type', () => {
|
||||
expect(
|
||||
getAlertTypeFromAlertDef({
|
||||
...defaultPostableAlertRuleV2,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
ruleType: 'anomaly_rule',
|
||||
}),
|
||||
).toBe(AlertTypes.ANOMALY_BASED_ALERT);
|
||||
});
|
||||
|
||||
it('keeps the stored alert type for other rules', () => {
|
||||
expect(
|
||||
getAlertTypeFromAlertDef({
|
||||
...defaultPostableAlertRuleV2,
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
ruleType: 'threshold_rule',
|
||||
}),
|
||||
).toBe(AlertTypes.LOGS_BASED_ALERT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThresholdStateFromAlertDef null channels', () => {
|
||||
it('falls back to an empty array so downstream consumers never see null', () => {
|
||||
const def: PostableAlertRuleV2 = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter, useHistory } from 'react-router-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { INITIAL_CREATE_ALERT_STATE } from '../constants';
|
||||
@@ -40,20 +41,20 @@ function Probe(): JSX.Element {
|
||||
}
|
||||
|
||||
function renderWithSearch(search: string): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: `/alerts/new${search}` },
|
||||
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const clickTestId = async (testId: string): Promise<void> => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId(testId));
|
||||
};
|
||||
|
||||
function serializeThreshold(thresholdValue: number): string {
|
||||
return encodeURIComponent(
|
||||
JSON.stringify([
|
||||
@@ -96,13 +97,6 @@ describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('starts new anomaly alerts at 3 deviations', () => {
|
||||
renderWithSearch(`?ruleType=anomaly_rule`);
|
||||
|
||||
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('applies the meter evaluation window when the preset is declared', () => {
|
||||
// Ingestion-style prefill: explicit in_total + meter preset.
|
||||
renderWithSearch(
|
||||
@@ -120,17 +114,17 @@ describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
|
||||
// Reproduces the #12137 regression: on the alert overview page the query builder
|
||||
// rewrites location.search after the alert loads, which used to re-run the prefill
|
||||
// effect and RESET the loaded threshold back to 0.
|
||||
function SearchMutator({
|
||||
to = '/alerts/overview?compositeQuery=normalized&ruleId=r1',
|
||||
}: {
|
||||
to?: string;
|
||||
}): JSX.Element {
|
||||
function SearchMutator(): JSX.Element {
|
||||
const routerHistory = useHistory();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mutate-search"
|
||||
onClick={(): void => routerHistory.replace(to)}
|
||||
onClick={(): void =>
|
||||
routerHistory.replace(
|
||||
'/alerts/overview?compositeQuery=normalized&ruleId=r1',
|
||||
)
|
||||
}
|
||||
>
|
||||
change search
|
||||
</button>
|
||||
@@ -138,7 +132,7 @@ function SearchMutator({
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
|
||||
it('keeps loaded thresholds when the query builder rewrites location.search', async () => {
|
||||
it('keeps loaded thresholds when the query builder rewrites location.search', () => {
|
||||
const initialAlertState = {
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
threshold: {
|
||||
@@ -153,158 +147,30 @@ describe('CreateAlertProvider — edit mode ignores URL prefill', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<Probe />
|
||||
<SearchMutator />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/overview?ruleId=r1' },
|
||||
<MemoryRouter initialEntries={['/alerts/overview?ruleId=r1']}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<Probe />
|
||||
<SearchMutator />
|
||||
</CreateAlertProvider>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
|
||||
// Simulate the query builder normalizing the query into the URL post-load.
|
||||
await clickTestId('mutate-search');
|
||||
fireEvent.click(screen.getByTestId('mutate-search'));
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
});
|
||||
});
|
||||
|
||||
function ThresholdSetter(): JSX.Element {
|
||||
const { thresholdState, setThresholdState } = useCreateAlertState();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="set-threshold"
|
||||
onClick={(): void =>
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [{ ...thresholdState.thresholds[0], thresholdValue: 245 }],
|
||||
})
|
||||
}
|
||||
>
|
||||
set threshold
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — create mode', () => {
|
||||
it('keeps edited thresholds when running the query rewrites location.search', async () => {
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
<ThresholdSetter />
|
||||
<SearchMutator to="/alerts/new?alertType=METRIC_BASED_ALERT&compositeQuery=ran" />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/new?alertType=METRIC_BASED_ALERT' },
|
||||
);
|
||||
|
||||
await clickTestId('set-threshold');
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
|
||||
await clickTestId('mutate-search');
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('245');
|
||||
});
|
||||
});
|
||||
|
||||
function AnomalySwitcher(): JSX.Element {
|
||||
const { setAlertType } = useCreateAlertState();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch-to-anomaly"
|
||||
onClick={(): void => setAlertType(AlertTypes.ANOMALY_BASED_ALERT)}
|
||||
>
|
||||
switch to anomaly
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateAlertProvider — edit mode type switch', () => {
|
||||
it('starts the anomaly condition at 3 deviations instead of the loaded threshold', async () => {
|
||||
const initialAlertState = {
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
threshold: {
|
||||
...INITIAL_CREATE_ALERT_STATE.threshold,
|
||||
thresholds: [
|
||||
{
|
||||
...INITIAL_CREATE_ALERT_STATE.threshold.thresholds[0],
|
||||
thresholdValue: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<Probe />
|
||||
<AnomalySwitcher />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/overview?ruleId=r1' },
|
||||
);
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('500');
|
||||
|
||||
await clickTestId('switch-to-anomaly');
|
||||
|
||||
expect(screen.getByTestId('threshold-value')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it('keeps the meter preset from bringing back a cumulative window for anomaly', async () => {
|
||||
render(
|
||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||
<Probe />
|
||||
<AnomalySwitcher />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: '/alerts/new?matchType=in_total&evaluationWindowPreset=meter',
|
||||
},
|
||||
);
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
|
||||
|
||||
await clickTestId('switch-to-anomaly');
|
||||
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
|
||||
it('moves a cumulative window back to rolling when switching to anomaly', async () => {
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
isEditMode
|
||||
ruleId="r1"
|
||||
initialAlertState={{
|
||||
...INITIAL_CREATE_ALERT_STATE,
|
||||
evaluationWindow: {
|
||||
...INITIAL_CREATE_ALERT_STATE.evaluationWindow,
|
||||
windowType: 'cumulative',
|
||||
timeframe: 'currentHour',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Probe />
|
||||
<AnomalySwitcher />
|
||||
</CreateAlertProvider>,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/overview?ruleId=r1' },
|
||||
);
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('cumulative');
|
||||
|
||||
await clickTestId('switch-to-anomaly');
|
||||
|
||||
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
TimeDuration,
|
||||
} from '../types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
@@ -265,6 +266,7 @@ describe('CreateAlertV2 Context Utils', () => {
|
||||
selectedQuery: 'B',
|
||||
operator: AlertThresholdOperator.IS_BELOW,
|
||||
matchType: AlertThresholdMatchType.ALL_THE_TIME,
|
||||
evaluationWindow: TimeDuration.TEN_MINUTES,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.DAILY,
|
||||
thresholds: [],
|
||||
@@ -278,6 +280,7 @@ describe('CreateAlertV2 Context Utils', () => {
|
||||
selectedQuery: 'C',
|
||||
operator: AlertThresholdOperator.IS_EQUAL_TO,
|
||||
matchType: AlertThresholdMatchType.ON_AVERAGE,
|
||||
evaluationWindow: TimeDuration.ONE_HOUR,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.WEEKLY,
|
||||
thresholds: [],
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
} from './types';
|
||||
|
||||
export const INITIAL_ALERT_STATE: AlertState = {
|
||||
@@ -34,11 +35,6 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||
color: Color.BG_SAKURA_500,
|
||||
};
|
||||
|
||||
export const INITIAL_ANOMALY_THRESHOLD: Threshold = {
|
||||
...INITIAL_CRITICAL_THRESHOLD,
|
||||
thresholdValue: 3,
|
||||
};
|
||||
|
||||
export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||
id: v4(),
|
||||
label: 'warning',
|
||||
@@ -73,6 +69,7 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
|
||||
selectedQuery: 'A',
|
||||
operator: AlertThresholdOperator.IS_ABOVE,
|
||||
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
evaluationWindow: TimeDuration.FIVE_MINUTES,
|
||||
algorithm: Algorithm.STANDARD,
|
||||
seasonality: Seasonality.HOURLY,
|
||||
thresholds: [INITIAL_CRITICAL_THRESHOLD],
|
||||
@@ -139,8 +136,8 @@ export const THRESHOLD_OPERATOR_OPTIONS = [
|
||||
];
|
||||
|
||||
export const ANOMALY_THRESHOLD_OPERATOR_OPTIONS = [
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'BELOW' },
|
||||
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
|
||||
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
|
||||
{ value: AlertThresholdOperator.ABOVE_BELOW, label: 'ABOVE/BELOW' },
|
||||
];
|
||||
|
||||
@@ -155,8 +152,16 @@ export const THRESHOLD_MATCH_TYPE_OPTIONS = [
|
||||
export const ANOMALY_THRESHOLD_MATCH_TYPE_OPTIONS = [
|
||||
{ value: AlertThresholdMatchType.AT_LEAST_ONCE, label: 'AT LEAST ONCE' },
|
||||
{ value: AlertThresholdMatchType.ALL_THE_TIME, label: 'ALL THE TIME' },
|
||||
{ value: AlertThresholdMatchType.ON_AVERAGE, label: 'ON AVERAGE' },
|
||||
{ value: AlertThresholdMatchType.LAST, label: 'LAST' },
|
||||
];
|
||||
|
||||
export const ANOMALY_TIME_DURATION_OPTIONS = [
|
||||
{ value: TimeDuration.FIVE_MINUTES, label: '5 minutes' },
|
||||
{ value: TimeDuration.TEN_MINUTES, label: '10 minutes' },
|
||||
{ value: TimeDuration.FIFTEEN_MINUTES, label: '15 minutes' },
|
||||
{ value: TimeDuration.ONE_HOUR, label: '1 hour' },
|
||||
{ value: TimeDuration.THREE_HOURS, label: '3 hours' },
|
||||
{ value: TimeDuration.FOUR_HOURS, label: '4 hours' },
|
||||
{ value: TimeDuration.TWENTY_FOUR_HOURS, label: '24 hours' },
|
||||
];
|
||||
|
||||
export const ANOMALY_ALGORITHM_OPTIONS = [
|
||||
|
||||
@@ -22,14 +22,9 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import {
|
||||
INITIAL_ANOMALY_THRESHOLD,
|
||||
INITIAL_CREATE_ALERT_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
} from './constants';
|
||||
import { INITIAL_CREATE_ALERT_STATE } from './constants';
|
||||
import {
|
||||
EvaluationWindowPreset,
|
||||
pickAlertPrefillParams,
|
||||
resolveUrlAlertPrefill,
|
||||
} from './resolveUrlAlertPrefill';
|
||||
import {
|
||||
@@ -134,10 +129,9 @@ export function CreateAlertProvider(
|
||||
const ruleNameFromURL = queryParams.get(QueryParams.ruleName);
|
||||
const yAxisUnitFromURL = queryParams.get(QueryParams.yAxisUnit);
|
||||
// Prefill declared in the URL; applied verbatim, agnostic of the producer.
|
||||
const prefillParams = pickAlertPrefillParams(location.search);
|
||||
const urlPrefill = useMemo(
|
||||
() => resolveUrlAlertPrefill(new URLSearchParams(prefillParams)),
|
||||
[prefillParams],
|
||||
() => resolveUrlAlertPrefill(new URLSearchParams(location.search)),
|
||||
[location.search],
|
||||
);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
@@ -147,41 +141,14 @@ export function CreateAlertProvider(
|
||||
return getInitialAlertTypeFromURL(queryParams, currentQuery);
|
||||
});
|
||||
|
||||
const resetThresholdState = useCallback((type: AlertTypes): void => {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'RESET',
|
||||
},
|
||||
});
|
||||
|
||||
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: [INITIAL_ANOMALY_THRESHOLD],
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAlertTypeChange = useCallback(
|
||||
(value: AlertTypes): void => {
|
||||
// Threshold and anomaly alerts share the metric query, so switching keeps it.
|
||||
const metricAlertTypes = [
|
||||
AlertTypes.METRICS_BASED_ALERT,
|
||||
AlertTypes.ANOMALY_BASED_ALERT,
|
||||
];
|
||||
const keepsQuery =
|
||||
value !== alertType &&
|
||||
metricAlertTypes.includes(value) &&
|
||||
metricAlertTypes.includes(alertType);
|
||||
const queryToRedirect = keepsQuery
|
||||
? currentQuery
|
||||
: mapQueryDataFromApi(buildInitialAlertDef(value).condition.compositeQuery);
|
||||
const queryToRedirect = buildInitialAlertDef(value);
|
||||
const currentQueryToRedirect = mapQueryDataFromApi(
|
||||
queryToRedirect.condition.compositeQuery,
|
||||
);
|
||||
redirectWithQueryBuilderData(
|
||||
queryToRedirect,
|
||||
currentQueryToRedirect,
|
||||
{
|
||||
[QueryParams.alertType]: value,
|
||||
[QueryParams.ruleType]:
|
||||
@@ -193,28 +160,8 @@ export function CreateAlertProvider(
|
||||
true,
|
||||
);
|
||||
setAlertType(value);
|
||||
if (isEditMode && keepsQuery) {
|
||||
resetThresholdState(value);
|
||||
}
|
||||
if (
|
||||
value === AlertTypes.ANOMALY_BASED_ALERT &&
|
||||
createAlertState.evaluationWindow.windowType !== 'rolling'
|
||||
) {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
alertType,
|
||||
createAlertState.evaluationWindow.windowType,
|
||||
currentQuery,
|
||||
isEditMode,
|
||||
redirectWithQueryBuilderData,
|
||||
resetThresholdState,
|
||||
setEvaluationWindow,
|
||||
],
|
||||
[redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const ruleNameAppliedRef = useRef(false);
|
||||
@@ -229,7 +176,12 @@ export function CreateAlertProvider(
|
||||
return;
|
||||
}
|
||||
|
||||
resetThresholdState(alertType);
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.THRESHOLD,
|
||||
action: {
|
||||
type: 'RESET',
|
||||
},
|
||||
});
|
||||
|
||||
if (urlPrefill.thresholds) {
|
||||
setCreateAlertState({
|
||||
@@ -261,10 +213,7 @@ export function CreateAlertProvider(
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER &&
|
||||
alertType !== AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
if (urlPrefill.evaluationWindowPreset === EvaluationWindowPreset.METER) {
|
||||
setCreateAlertState({
|
||||
slice: CreateAlertSlice.EVALUATION_WINDOW,
|
||||
action: {
|
||||
@@ -294,14 +243,7 @@ export function CreateAlertProvider(
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
alertType,
|
||||
urlPrefill,
|
||||
ruleNameFromURL,
|
||||
yAxisUnitFromURL,
|
||||
isEditMode,
|
||||
resetThresholdState,
|
||||
]);
|
||||
}, [alertType, urlPrefill, ruleNameFromURL, yAxisUnitFromURL, isEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -7,13 +7,6 @@ import {
|
||||
Threshold,
|
||||
} from './types';
|
||||
|
||||
const PREFILL_PARAMS = [
|
||||
QueryParams.thresholds,
|
||||
QueryParams.matchType,
|
||||
QueryParams.compareOp,
|
||||
QueryParams.evaluationWindowPreset,
|
||||
];
|
||||
|
||||
export enum EvaluationWindowPreset {
|
||||
METER = 'meter',
|
||||
}
|
||||
@@ -46,18 +39,6 @@ function parseEvaluationWindowPreset(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function pickAlertPrefillParams(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
const prefillParams = new URLSearchParams();
|
||||
PREFILL_PARAMS.forEach((key) => {
|
||||
const value = params.get(key);
|
||||
if (value !== null) {
|
||||
prefillParams.set(key, value);
|
||||
}
|
||||
});
|
||||
return prefillParams.toString();
|
||||
}
|
||||
|
||||
/** URL → declarative prefill plan; the consumer applies it without knowing the producer. */
|
||||
export function resolveUrlAlertPrefill(
|
||||
params: URLSearchParams,
|
||||
|
||||
@@ -119,11 +119,23 @@ export interface AlertThresholdState {
|
||||
// canonical enum value.
|
||||
operator: AlertThresholdOperator | string;
|
||||
matchType: AlertThresholdMatchType | string;
|
||||
evaluationWindow: string;
|
||||
algorithm: string;
|
||||
seasonality: string;
|
||||
thresholds: Threshold[];
|
||||
}
|
||||
|
||||
export enum TimeDuration {
|
||||
ONE_MINUTE = '1m0s',
|
||||
FIVE_MINUTES = '5m0s',
|
||||
TEN_MINUTES = '10m0s',
|
||||
FIFTEEN_MINUTES = '15m0s',
|
||||
ONE_HOUR = '1h0m0s',
|
||||
THREE_HOURS = '3h0m0s',
|
||||
FOUR_HOURS = '4h0m0s',
|
||||
TWENTY_FOUR_HOURS = '24h0m0s',
|
||||
}
|
||||
|
||||
export enum Algorithm {
|
||||
STANDARD = 'standard',
|
||||
}
|
||||
@@ -138,6 +150,7 @@ export type AlertThresholdAction =
|
||||
| { type: 'SET_SELECTED_QUERY'; payload: string }
|
||||
| { type: 'SET_OPERATOR'; payload: AlertThresholdOperator }
|
||||
| { type: 'SET_MATCH_TYPE'; payload: AlertThresholdMatchType }
|
||||
| { type: 'SET_EVALUATION_WINDOW'; payload: string }
|
||||
| { type: 'SET_ALGORITHM'; payload: string }
|
||||
| { type: 'SET_SEASONALITY'; payload: string }
|
||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||
|
||||
@@ -124,10 +124,6 @@ export const alertThresholdReducer = (
|
||||
return { ...state, operator: action.payload };
|
||||
case 'SET_MATCH_TYPE':
|
||||
return { ...state, matchType: action.payload };
|
||||
case 'SET_ALGORITHM':
|
||||
return { ...state, algorithm: action.payload };
|
||||
case 'SET_SEASONALITY':
|
||||
return { ...state, seasonality: action.payload };
|
||||
case 'SET_THRESHOLDS':
|
||||
return { ...state, thresholds: action.payload };
|
||||
case 'RESET':
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
export const useIsAnomalyDetectionAvailable = (): boolean => {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
return isCloudUser || isEnterpriseSelfHostedUser;
|
||||
};
|
||||
@@ -4,8 +4,6 @@ import { Spin } from 'antd';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { getRandomColor } from 'container/ExplorerOptions/utils';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -250,18 +248,13 @@ export {
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
const isAnomalyRule =
|
||||
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
alertDef.condition.thresholds?.spec.map((threshold) => ({
|
||||
id: v4(),
|
||||
label: threshold.name,
|
||||
// anomaly targets are signed z-scores; the operator carries the direction
|
||||
thresholdValue: isAnomalyRule
|
||||
? Math.abs(threshold.target)
|
||||
: threshold.target,
|
||||
thresholdValue: threshold.target,
|
||||
recoveryThresholdValue: null,
|
||||
unit: threshold.targetUnit,
|
||||
color: getColorForThreshold(threshold.name),
|
||||
@@ -275,23 +268,9 @@ export function getThresholdStateFromAlertDef(
|
||||
matchType:
|
||||
alertDef.condition.thresholds?.spec[0].matchType ||
|
||||
AlertThresholdMatchType.AT_LEAST_ONCE,
|
||||
algorithm:
|
||||
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
|
||||
seasonality:
|
||||
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
|
||||
};
|
||||
}
|
||||
|
||||
// Anomaly rules are stored as metric alerts; only the rule type tells them apart.
|
||||
export function getAlertTypeFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): AlertTypes {
|
||||
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
return alertDef?.alertType as AlertTypes;
|
||||
}
|
||||
|
||||
export function getCreateAlertLocalStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): CreateAlertState {
|
||||
|
||||
@@ -26,11 +26,7 @@ const COLUMNS = [
|
||||
|
||||
function SlackActions(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
|
||||
|
||||
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
|
||||
@@ -55,9 +51,6 @@ function SlackActions(): JSX.Element {
|
||||
key={column}
|
||||
label={t(`label_slack_action_${keyFor(column)}`)}
|
||||
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
|
||||
error={
|
||||
column === 'url' ? errors.actions?.[index]?.url?.message : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
{...register(`actions.${index}.${column}` as const)}
|
||||
|
||||
@@ -14,11 +14,7 @@ import styles from './RowEditor.module.scss';
|
||||
// Slack renders these as the attachment's table of short or full-width entries.
|
||||
function SlackFields(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
|
||||
|
||||
return (
|
||||
@@ -50,7 +46,6 @@ function SlackFields(): JSX.Element {
|
||||
<FormField
|
||||
label={t('label_slack_field_value')}
|
||||
tooltip={t('tooltip_slack_field_value')}
|
||||
error={errors.fields?.[index]?.value?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`fields.${index}.value`)}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.auth {
|
||||
--radio-group-display: flex;
|
||||
}
|
||||
@@ -1,30 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
|
||||
import {
|
||||
ChannelFormValues,
|
||||
WebhookAuth,
|
||||
} from '../../CreateAlertChannels/types';
|
||||
import { webhookAuthOf } from '../../CreateAlertChannels/utils';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormCard from '../FormCard';
|
||||
import FormField from '../FormField';
|
||||
|
||||
import styles from './Webhook.module.scss';
|
||||
|
||||
function WebhookSettings(): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext<ChannelFormValues>();
|
||||
const [webhookAuth, username, password, bearerToken] = useWatch({
|
||||
control,
|
||||
name: ['webhookAuth', 'username', 'password', 'bearerToken'],
|
||||
});
|
||||
const auth = webhookAuthOf({ webhookAuth, username, password, bearerToken });
|
||||
} = useFormContext<ChannelSpecFormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,75 +28,35 @@ function WebhookSettings(): JSX.Element {
|
||||
data-testid="webhook-url-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t('field_webhook_auth')}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="webhookAuth"
|
||||
render={({ field }): JSX.Element => (
|
||||
<div className={styles.auth}>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
aria-label={t('field_webhook_auth')}
|
||||
value={auth}
|
||||
onChange={field.onChange}
|
||||
testId="webhook-auth-radio"
|
||||
items={[
|
||||
{
|
||||
value: WebhookAuth.none,
|
||||
label: t('option_webhook_auth_none'),
|
||||
},
|
||||
{
|
||||
value: WebhookAuth.basic,
|
||||
label: t('option_webhook_auth_basic'),
|
||||
},
|
||||
{
|
||||
value: WebhookAuth.bearer,
|
||||
label: t('option_webhook_auth_bearer'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
label={t('field_webhook_username')}
|
||||
help={t('help_webhook_username')}
|
||||
error={errors.username?.message}
|
||||
>
|
||||
<Input {...register('username')} data-testid="webhook-username-textbox" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Password (optional)"
|
||||
help={t('help_webhook_password')}
|
||||
error={errors.password?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('field_webhook_bearer_token')}
|
||||
help={t('help_webhook_bearer_token')}
|
||||
error={errors.bearerToken?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('bearerToken')}
|
||||
type="password"
|
||||
data-testid="webhook-bearer-token-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
{auth === WebhookAuth.basic && (
|
||||
<>
|
||||
<FormField
|
||||
label={t('field_webhook_username')}
|
||||
error={errors.username?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('username')}
|
||||
data-testid="webhook-username-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('field_webhook_password')}
|
||||
error={errors.password?.message}
|
||||
>
|
||||
<Input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
data-testid="webhook-password-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{auth === WebhookAuth.bearer && (
|
||||
<FormField
|
||||
label={t('field_webhook_bearer_token')}
|
||||
help={t('help_webhook_bearer_token')}
|
||||
error={errors.bearerToken?.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
{...register('bearerToken')}
|
||||
type="password"
|
||||
aria-required
|
||||
data-testid="webhook-bearer-token-textbox"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</FormCard>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user