Compare commits

..

10 Commits

Author SHA1 Message Date
Naman Verma
3a5078e29b Merge branch 'main' into nv/alerts-migration 2026-10-22 02:05:57 +05:30
Ashwin Bhatkal
9fc6fbb9fa test(e2e): cover the notification channel list, create and delete flows (#13216)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

End-to-end cover for the rebuilt notification channel screens.
`edit.spec.ts` came with the v2 move; this adds the flows around it.

- **list** — search narrows the table, the type filter keeps only that
type, a search that matches nothing offers to clear itself, clicking a
row opens the channel, and View JSON masks the secrets.
- **create** — a webhook channel saves and appears in the list, and
saving without a name reports the missing field.
- **delete** — removing from the row menu takes the channel out of the
list.
- **one of each type** — eleven create specs and eleven edit specs, one
per channel type. Create fills only what the validator refuses to save
without and checks the row appears; edit seeds the channel through the
API, changes its first required field and checks the change survives a
reload. A type whose settings component or validator drifts fails here
rather than in support.

Everything the specs need lives in `drivers/channels.ts`, following
`drivers/routing.ts`: a `channels` driver for the list, the form and the
API, a worker-scoped `channelSet` that seeds one Slack and one email
channel sharing a prefix, and `CHANNEL_TYPES`, the one table both
per-type specs read so a new channel type is added in a single place.
The specs touch `page` only where they wait on a URL.

The type fixtures respect the real validators rather than guessing:
Google Chat needs https on `chat.googleapis.com`, Jira an https
`.atlassian.net` host, incident.io an id after `/v2/alert_events/http/`,
and Telegram a non-zero chat id.
2026-10-21 19:57:19 +00:00
Ashwin Bhatkal
93d054ee02 fix(notification-channels): apply the review follow-ups on the channel screens (#13214)
#### Description

From the review of SigNoz/signoz#12976 (channel list and form):

- Add the missing spacing and tooltip - fixed. Tooltip has not been a
common pattern for this though. We should make changes once the button
component supports it
- Add a discard button to the form - fixed
- Lay out the form's action buttons the way the alert pages do - fixed
- Fix light mode, which still looks off on these screens. - fixed
- Fix the text that is not visible in light mode. - fixed
- When the list is filtered by a kind, such as Jira or Google Chat, show
a button that creates a channel of that kind - fixed.
- Fix the field styling, which still looks off - fixed, updated design
- Give the TextArea the same background as the other inputs in light
mode. It differs because it comes from antd. - fixed background and
borders as well.
- Make the delete button the same height as the input next to it -
updated design, fixed
- allow search term api filtering in notification channel search api -
fixed
- show no data state on load aside from the empty state with filters
applied - fixed, an empty workspace gets its own screen with a create
action
- alerts list icon should be same size as the new channel button -
flipped and fixed
- the help icon should be to the right of the new channel button instead
of the left - fixed

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/598


#### Screenshots / Screen Recordings

Every story under `Pages/Alerts/Channels`, shot in dark and light before
and after: **https://claude.ai/artifact/6FQ2pawzEhK5hZhWsG1tB2**

130 pairs, 112 changed, 6 new screens. The page groups them, so you can
open just the form screens, just the list, or just the Slack sections.

One caveat on that page: it was captured before the global button font
rule was reverted, so it counts more changed screens than the branch now
has. The groups and what each one shows are still accurate.
2026-10-21 19:57:18 +00:00
Naman Verma
7ebb1910b9 fix: remove nilnil return 2026-10-21 13:50:18 +05:30
Naman Verma
1e1d6f8d24 Merge branch 'main' into nv/alerts-migration 2026-10-21 13:39:57 +05:30
Naman Verma
6f6eea7187 fix: 400 on malformed JSON, missing alertType, unknown enums 2026-10-19 12:00:16 +05:30
Naman Verma
0bec6c5494 Merge branch 'main' into nv/alerts-migration 2026-10-18 09:41:38 +05:30
Naman Verma
d6744c1d8e chore: remove v1 rule apis 2026-10-16 15:20:38 +05:30
Naman Verma
6476be26bb Merge branch 'main' into nv/alerts-migration
# Conflicts:
#	pkg/query-service/app/http_handler.go
2026-10-16 14:56:00 +05:30
Naman Verma
06c54c6483 feat: migrate all rules to v2 schema 2026-10-16 11:17:10 +05:30
93 changed files with 2880 additions and 3851 deletions

View File

@@ -105,6 +105,13 @@ is there to look at. Shooting two narrow runs and diffing those against each
other has the same problem. Either shoot the full set twice, or skip `--noise`
and explain the few movers by hand.
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the
noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run
hides a real 8% change in the real one. Compare the two numbers before trusting
the mark. When most of the flaky marks are hiding real changes, run the diff
without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing
real is filtered out of the reviewer's view.
That is the trade: a second full capture costs about as long as the first, so on
a small diff it is often quicker to crop the two or three movers and say what
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
@@ -138,6 +145,15 @@ eye.
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how
many to shoot at once by sampling free memory, and off Linux it reads
`os.freemem()`, which counts only truly free pages. macOS parks almost
everything in "inactive" instead, which is reclaimable on demand, so the
sampler can read ~100 MB on a 25 GB machine and shrink to one forever.
`--jobs` does not help: it is only an upper cap. Check `parallelism` in
`<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes
inactive, that is this. A 124-shot run takes about 90 minutes pinned and about
6 minutes at four at a time, so it is worth spotting before a long capture.
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.

View File

@@ -3783,13 +3783,6 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4532,7 +4525,6 @@ 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'
@@ -4548,7 +4540,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4562,7 +4553,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4648,18 +4638,6 @@ 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:
@@ -4739,16 +4717,6 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4937,104 +4905,6 @@ 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
@@ -5148,11 +5018,6 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -9791,35 +9656,34 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
labels:
additionalProperties:
type: string
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
enum:
- v2alpha1
type: string
source:
deprecated: true
type: string
version:
deprecated: true
type: string
required:
- alert
- alertType
- ruleType
- condition
- evaluation
- schemaVersion
- notificationSettings
type: object
RuletypesPostableRuleView:
properties:
@@ -9880,12 +9744,8 @@ components:
type: string
disabled:
type: boolean
evalWindow:
type: string
evaluation:
$ref: '#/components/schemas/RuletypesEvaluationEnvelope'
frequency:
type: string
id:
type: string
labels:
@@ -9894,15 +9754,14 @@ components:
type: object
notificationSettings:
$ref: '#/components/schemas/RuletypesNotificationSettings'
preferredChannels:
items:
type: string
type: array
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
schemaVersion:
enum:
- v2alpha1
type: string
source:
deprecated: true
type: string
state:
$ref: '#/components/schemas/RuletypesAlertState'
@@ -9912,6 +9771,7 @@ components:
updatedBy:
type: string
version:
deprecated: true
type: string
required:
- id
@@ -9920,6 +9780,9 @@ components:
- alertType
- ruleType
- condition
- evaluation
- schemaVersion
- notificationSettings
type: object
RuletypesRuleCondition:
properties:
@@ -9932,10 +9795,6 @@ components:
type: string
compositeQuery:
$ref: '#/components/schemas/RuletypesAlertCompositeQuery'
matchType:
$ref: '#/components/schemas/RuletypesMatchType'
op:
$ref: '#/components/schemas/RuletypesCompareOperator'
requireMinPoints:
type: boolean
requiredNumPoints:
@@ -9944,16 +9803,11 @@ components:
$ref: '#/components/schemas/RuletypesSeasonality'
selectedQueryName:
type: string
target:
format: double
nullable: true
type: number
targetUnit:
type: string
thresholds:
$ref: '#/components/schemas/RuletypesRuleThresholdData'
required:
- compositeQuery
- thresholds
type: object
RuletypesRuleThresholdData:
discriminator:
@@ -22578,56 +22432,6 @@ paths:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true
description: 'This endpoint lists all alert rules with their current evaluation
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
pagination.'
operationId: ListRules
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/RuletypesRule'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List alert rules
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint creates a new alert rule
@@ -22786,13 +22590,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -22827,22 +22631,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
@@ -23738,13 +23556,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -23779,22 +23597,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
@@ -24591,13 +24423,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -24632,22 +24464,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
@@ -26022,13 +25868,13 @@ paths:
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Anomaly rules are not yet supported under schemaVersion
v2alpha1, so this example uses the v1 shape. Wraps a builder query
in the `anomaly` function with daily seasonality SigNoz compares
each point against the forecast for that time of day. Fires when
the anomaly score stays below the threshold for the entire window;
`requireMinPoints` guards against noisy intervals.
summary: Metric anomaly rule (v1 only)
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
for that time of day. The threshold is compared against the signed
anomaly score, so `below` with a target of -2 fires when an abrupt
drop holds for the entire window; `requireMinPoints` guards against
noisy intervals.
summary: Metric anomaly rule
value:
alert: Anomalous drop in ingested spans
alertType: METRIC_BASED_ALERT
@@ -26063,22 +25909,36 @@ paths:
stepInterval: 21600
type: builder_query
queryType: builder
matchType: all_the_times
op: below
requireMinPoints: true
requiredNumPoints: 3
seasonality: daily
selectedQueryName: A
target: 2
thresholds:
kind: basic
spec:
- channels:
- slack-ingestion
matchType: all_the_times
name: warning
op: below
target: -2
description: Detect an abrupt drop in span ingestion using a z-score
anomaly function
evalWindow: 24h
frequency: 3h
evaluation:
kind: rolling
spec:
evalWindow: 24h
frequency: 3h
labels:
severity: warning
preferredChannels:
- slack-ingestion
notificationSettings:
renotify:
alertStates:
- firing
enabled: true
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL

View File

@@ -105,15 +105,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -420,12 +420,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
}
byt, err := json.Marshal(ar)

View File

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

View File

@@ -12,6 +12,7 @@
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
@@ -186,8 +187,32 @@
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
"button_discard_channel": "Discard",
"button_discard_confirm": "Discard",
"discard_changes_title": "Discard your changes?",
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
"tooltip_save_no_changes": "Nothing has changed yet.",
"entry_slack_field": "Field",
"entry_slack_action": "Action",
"entry_incidentio_metadata": "Entry",
"label_slack_field_title": "Title",
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
"label_slack_field_value": "Value",
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
"label_incidentio_metadata_key": "Key",
"label_incidentio_metadata_value": "Value",
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
"tooltip_slack_action_text": "The label on the button.",
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",

View File

@@ -15,12 +15,12 @@
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
color: var(--l2-foreground);
max-width: 400px;
}

View File

@@ -9,18 +9,18 @@
}
.icon {
color: var(--text-vanilla-400);
color: var(--l2-foreground);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
color: var(--l2-foreground);
max-width: 400px;
}

View File

@@ -0,0 +1,24 @@
/* The space sits between the way back and the title, rather than above both of
them, so the two read as separate steps. */
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-height: 26px;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--l1-foreground);
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
}

View File

@@ -0,0 +1,56 @@
import { ReactNode } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BackHeader.module.scss';
export interface BackHeaderProps {
/** Where the page came from, named as the place rather than the action. */
backLabel: string;
backRoute: string;
title: ReactNode;
testId?: string;
}
/**
* A way back on its own line, then the page's own title. The two rows keep the
* title clear of whatever sits beside it, such as a copy button.
*/
function BackHeader({
backLabel,
backRoute,
title,
testId,
}: BackHeaderProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
return (
<>
<div className={styles.header} data-testid={testId}>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<ArrowLeft size={12} />}
onClick={(e: React.MouseEvent): void => {
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
}}
testId="back-link"
>
{backLabel}
</Button>
<div className={styles.title}>{title}</div>
</div>
<Divider className={styles.divider} />
</>
);
}
BackHeader.defaultProps = {
testId: undefined,
};
export default BackHeader;

View File

@@ -3,23 +3,21 @@ import { Columns3 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import type { TableColumnDef } from 'components/TanStackTableView';
import {
hideColumn,
showColumn,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './ColumnSelector.module.scss';
import type { TableColumnDef } from './types';
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
interface ColumnSelectorProps<TData> {
columns: TableColumnDef<TData>[];
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
storageKey: string;
testId?: string;
}
function ColumnSelector<TData>({
columns,
storageKey,
testId = 'column-selector-button',
}: ColumnSelectorProps<TData>): JSX.Element {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
@@ -47,7 +45,7 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
testId="alert-columns-button"
testId={testId}
>
Columns
</Button>
@@ -77,4 +75,8 @@ function ColumnSelector<TData>({
);
}
ColumnSelector.defaultProps = {
testId: 'column-selector-button',
};
export default ColumnSelector;

View File

@@ -2,6 +2,7 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export { default as ColumnSelector } from './ColumnSelector';
export * from './TanStackHoverTooltip';
export * from './TanStackTableStateContext';
export * from './types';

View File

@@ -101,6 +101,16 @@ const SPECS: Record<string, Record<string, unknown>> = {
metadata: { team: 'ops' },
sendResolved: true,
},
telegram: {
apiUrl: 'https://api.telegram.org',
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 't',
message: 'm',
disableNotifications: true,
sendResolved: true,
},
};
describe('load then save round trip', () => {

View File

@@ -22,13 +22,26 @@
min-width: 0;
}
/* Laid out like the alert pages: discard on the left, test and save together on
the right. Sticky rather than fixed, so it stays inside the page's own scroll
instead of adding another offset over the bottom strip. */
.actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: space-between;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l1-border);
border-top: 1px solid var(--l2-border);
background: var(--l1-background);
}
.actionGroup {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
@@ -36,3 +49,21 @@
opacity: 0.5;
cursor: not-allowed;
}
/* The @signozhq inputs leave their surface transparent and take the card's
colour. antd paints its textareas instead, so beside them they read as a
different control, most visibly in light mode. */
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
.fields :global(textarea.ant-input),
.fields :global(textarea.ant-input:hover),
.fields :global(textarea.ant-input:focus),
.fields :global(textarea.ant-input-focused) {
background: var(--input-background, transparent);
border-width: var(--input-border-width, 1px);
border-style: var(--input-border-style, solid);
border-color: var(--input-border-color, var(--border));
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
box-shadow: var(--input-box-shadow, none);
color: inherit;
}

View File

@@ -1,8 +1,8 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
gap: var(--spacing-3);
margin-bottom: var(--spacing-6);
}
.label {

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button } from '@signozhq/ui/button';
import { Input as AntdInput } from 'antd';
@@ -10,6 +10,7 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
const { TextArea } = AntdInput;
@@ -139,10 +140,19 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.row}>
<div className={styles.field}>
<RowEntry
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
noun={t('entry_incidentio_metadata')}
removeLabel={t('button_incidentio_remove_metadata')}
removeTestId={`incidentio-metadata-remove-${index}`}
onRemove={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
>
<FormField label={t('label_incidentio_metadata_key')}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
@@ -155,8 +165,9 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-key-${index}`}
/>
</div>
<div className={styles.field}>
</FormField>
<FormField label={t('label_incidentio_metadata_value')}>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
@@ -169,22 +180,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-value-${index}`}
/>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('button_incidentio_remove_metadata')}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
testId={`incidentio-metadata-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
</FormField>
</RowEntry>
))}
<div className={styles.add}>
<Button
variant="dashed"

View File

@@ -1,3 +1,10 @@
/* One height for every control a repeating row renders, so the remove button,
the add button and the inputs beside them agree. */
.entry,
.add {
--row-control-height: 28px;
}
.row {
display: flex;
align-items: center;
@@ -23,7 +30,9 @@
}
.add {
margin-top: var(--spacing-1);
/* Sits with the blocks it adds to, not against the next field's label. */
margin-top: var(--spacing-2);
--button-height: var(--row-control-height);
}
@media (max-width: 720px) {
@@ -35,3 +44,52 @@
max-width: none;
}
}
/* One entry per block, every input labelled. A row of bare boxes gave no way to
tell which column was which once a value was typed. The block keeps a readable
width while the rest of the form fills the page, so its two columns do not
stretch into a row of six. */
.entry {
max-width: 760px;
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--l2-background);
padding: var(--spacing-6) var(--spacing-7);
margin-bottom: var(--spacing-4);
}
.entryTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
/* The remove control is an icon button, so give it the height the design
system gives a small control rather than letting the glyph set it. */
--button-height: var(--row-control-height);
--button-icon-size: 14px;
}
.entryIndex {
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.entryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 var(--spacing-6);
}
/* The toggle reads as a sentence, so it takes the whole width rather than
sitting in a column sized for an input. */
.entryWide {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.entryGrid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -0,0 +1,50 @@
import { ReactNode } from 'react';
import { Minus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './RowEditor.module.scss';
interface RowEntryProps {
/** Zero-based; shown to the reader as a count from one. */
index: number;
/** Singular noun for one entry, such as "Field" or "Action". */
noun: string;
removeLabel: string;
removeTestId: string;
onRemove: () => void;
children: ReactNode;
}
function RowEntry({
index,
noun,
removeLabel,
removeTestId,
onRemove,
children,
}: RowEntryProps): JSX.Element {
return (
<div className={styles.entry}>
<div className={styles.entryTop}>
<Typography.Text size="sm" className={styles.entryIndex}>
{noun} {index + 1}
</Typography.Text>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={removeLabel}
onClick={onRemove}
testId={removeTestId}
>
<Minus size={14} />
</Button>
</div>
<div className={styles.entryGrid}>{children}</div>
</div>
);
}
export default RowEntry;

View File

@@ -42,7 +42,9 @@ function Slack(): JSX.Element {
>
<Input {...register('channel')} data-testid="slack-channel-textbox" />
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
@@ -82,7 +84,12 @@ function Slack(): JSX.Element {
)}
/>
</FormField>
</FormCard>
<FormCard
title={t('section_advanced')}
description={t('description_slack_advanced')}
>
<FormField
label={t('field_slack_color')}
help={t('help_slack_color')}

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
@@ -29,49 +29,37 @@ function SlackActions(): JSX.Element {
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const labelFor = (column: ActionColumn): string =>
t(`label_slack_action_${column.replace('.text', '')}`);
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
return (
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
<FormField
label={t('field_slack_actions')}
tooltip={t('tooltip_slack_actions')}
>
<div>
{fields.length > 0 && (
<div className={styles.row}>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<Typography.Text size="sm" className={styles.heading}>
{labelFor(column)}
</Typography.Text>
</div>
))}
</div>
)}
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_action')}
removeLabel={t('remove_slack_action')}
removeTestId={`slack-action-remove-${index}`}
onRemove={(): void => remove(index)}
>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<FormField
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
>
<Input
{...register(`actions.${index}.${column}` as const)}
placeholder={t(
`placeholder_slack_action_${column.replace('.text', '')}`,
)}
testId={`slack-action-${column.replace('.text', '')}-${index}`}
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
testId={`slack-action-${keyFor(column)}-${index}`}
/>
</div>
</FormField>
))}
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_action')}
onClick={(): void => remove(index)}
testId={`slack-action-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
</RowEntry>
))}
</div>

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
@@ -8,6 +8,7 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
@@ -17,52 +18,61 @@ function SlackFields(): JSX.Element {
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
<FormField
label={t('field_slack_fields')}
tooltip={t('tooltip_slack_fields')}
>
<div>
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
<div className={styles.field}>
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_field')}
removeLabel={t('remove_slack_field')}
removeTestId={`slack-field-remove-${index}`}
onRemove={(): void => remove(index)}
>
<FormField
label={t('label_slack_field_title')}
tooltip={t('tooltip_slack_field_title')}
>
<Input
{...register(`fields.${index}.title`)}
placeholder={t('placeholder_slack_field_title')}
testId={`slack-field-title-${index}`}
/>
</div>
<div className={styles.field}>
</FormField>
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
>
<Input
{...register(`fields.${index}.value`)}
placeholder={t('placeholder_slack_field_value')}
testId={`slack-field-value-${index}`}
/>
</FormField>
<div className={styles.entryWide}>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
</div>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_field')}
onClick={(): void => remove(index)}
testId={`slack-field-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
</RowEntry>
))}
</div>

View File

@@ -24,6 +24,7 @@ describe('FormAlertChannels', () => {
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
onDiscardHandler={jest.fn()}
testingState={false}
savingState={false}
editing

View File

@@ -1,6 +1,7 @@
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
@@ -48,10 +49,12 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -115,7 +118,10 @@ function FormAlertChannels({
/>
</FormField>
<FormField label={t('field_send_resolved')}>
<FormField
label={t('field_send_resolved')}
tooltip={t('tooltip_send_resolved')}
>
<Controller
control={control}
name="sendResolved"
@@ -131,7 +137,11 @@ function FormAlertChannels({
/>
</FormField>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<FormField
label={t('field_channel_type')}
tooltip={t('tooltip_channel_type')}
htmlFor="channel-type"
>
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
@@ -148,30 +158,45 @@ function FormAlertChannels({
</fieldset>
<div className={styles.actions}>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<Button
variant="outlined"
color="secondary"
size="md"
testId="discard-channel-button"
prefix={<X size={14} />}
onClick={onDiscardHandler}
>
{t('button_discard_channel')}
</Button>
<div className={styles.actionGroup}>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
disabled={!hasChanges}
disabledTooltip={t('tooltip_save_no_changes')}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
</div>
</div>
</form>
</FormProvider>
@@ -184,12 +209,15 @@ interface FormAlertChannelsProps {
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
onDiscardHandler: () => void;
testingState: boolean;
savingState: boolean;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
hasChanges?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
@@ -199,6 +227,7 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

View File

@@ -1,2 +1 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu, ColumnSelector } from './components';
import { ActionsMenu } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
@@ -134,7 +134,11 @@ function ListAlertRules(): JSX.Element {
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
{addNewAlert && (
<Button
variant="solid"

View File

@@ -22,12 +22,17 @@ export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
// Both token colours are picked to read on a dark surface, so on a light one
// they wash out, the keys worst of all. Monaco's own `vs` palette is built
// for that surface, so let it keep its colours there, as the role viewer does.
const rules: { token: string; foreground: string }[] = [];
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
if (isDark(background)) {
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
}
const colors: Record<string, string> = {};

View File

@@ -58,6 +58,12 @@ export const Default: Story = {};
/** A workspace with nowhere to send an alert yet. */
export const NoChannels: Story = {
args: { channels: 0 },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByTestId('channels-empty-state', undefined, UNTIL_LOADED);
await canvas.findByTestId('channels-empty-create');
},
};
/** Data: the channel list's existing loading spinner. */
@@ -105,10 +111,24 @@ export const KindFilter: Story = {
UNTIL_LOADED,
);
await canvas.findByText('sre-email');
// the heading and the create button both name the filtered type
await canvas.findByText('All Email channels');
},
};
/** Interaction: a search matching nothing offers to clear itself. */
/** Interaction: the column menu, which turns table columns off and on. */
export const ColumnSelector: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByTestId('channels-columns'));
await screen.findByText('Toggle Columns');
},
};
/** Interaction: a search that matches no channel. */
export const NoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
@@ -120,6 +140,21 @@ export const NoResults: Story = {
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
},
};
/** Interaction: clearing the search from the empty state brings the list back. */
export const ClearSearch: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.type(
canvas.getByTestId('channels-search'),
'no-such-channel',
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
},

View File

@@ -90,13 +90,16 @@ export const SaveFailure: Story = {
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
untilLoaded,
),
const canvas = within(canvasElement);
// Save is off until the form is edited, so the failure is reached through
// a real change rather than an empty submit.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, untilLoaded),
'-edited',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Storybook forced channel failure');
},
};

View File

@@ -82,6 +82,12 @@ export const NoCreate: Story = {
await expectDisabled(
await canvas.findByTestId('test-channel-button', undefined, PAGE_LOAD),
);
// Save is also off until the form is edited, so the edit comes first and
// what is left is the permission.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
false,
@@ -168,12 +174,13 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(save, false);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
},
};

View File

@@ -16,6 +16,11 @@ import {
} from '../../stories/__story_mockdata__/alerts';
const ACTIONS = 'Channel · actions';
const ROUTE = 'Channel · route';
/** The `?kind=` values worth reaching: a known type, and one the form rejects. */
const URL_KINDS = ['none', 'jira', 'not-a-kind'] as const;
type UrlKind = (typeof URL_KINDS)[number];
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
@@ -29,7 +34,8 @@ const rejectChannelAction: MockResolver = (_req, res, ctx) =>
/**
* The form holds the channel type in component state, so the type itself is
* stories with a `play` that picks one rather than a control; saving and
* testing the channel are.
* testing the channel are. The type can also arrive in the URL, which the list
* uses to carry its filter over, so that is a control on the route.
*/
export const channelsNewMocks = defineStoryMocks({
controls: {
@@ -43,6 +49,11 @@ export const channelsNewMocks = defineStoryMocks({
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
kindInUrl: choiceControl<UrlKind>('Channel type in the URL', {
group: ROUTE,
options: URL_KINDS,
value: 'none',
}),
},
handlers: (values) => [
rest.post(
@@ -55,5 +66,10 @@ export const channelsNewMocks = defineStoryMocks({
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
),
],
config: () => ({ route: '/alerts/channels/new' }),
config: (values) => ({
route:
values.kindInUrl === 'none'
? '/alerts/channels/new'
: `/alerts/channels/new?kind=${values.kindInUrl}`,
}),
});

View File

@@ -58,9 +58,16 @@ export const ChannelTypeSelectOpen: Story = {
/** Mutation: saving without a channel name shows the form's required-field feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('save-channel-button'),
const canvas = within(canvasElement);
// Save stays off until the form is touched, so filling the webhook first is
// how a missing name is reached.
await userEvent.type(
await canvas.findByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Channel name is mandatory');
},
};
@@ -231,3 +238,38 @@ export const RequiredFields: Story = {
);
},
};
/**
* The type carried in the URL. Filtering the list by a type and pressing its
* create button lands here with `?kind=`, so the form opens on that type with
* nothing to pick.
*/
export const KindFromUrl: Story = {
args: { kindInUrl: 'jira' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Jira$/);
},
};
/**
* A `?kind=` the app does not have. The form opens on its own default rather
* than on an empty type, so a stale or hand-edited link still works.
*/
export const UnknownKindInUrl: Story = {
args: { kindInUrl: 'not-a-kind' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Slack$/);
},
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { expect, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -81,12 +81,13 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox', undefined, PAGE_LOAD),
'check-failed',
);
await expectDisabled(save, false);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
},
};

View File

@@ -11,13 +11,12 @@
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: var(--spacing-6) var(--spacing-8);
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
@@ -25,7 +24,7 @@
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
gap: var(--spacing-4);
}
.headingBlock {

View File

@@ -7,10 +7,11 @@ import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
@@ -25,7 +26,9 @@ import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import ChannelsEmptyState from './components/ChannelsEmptyState/ChannelsEmptyState';
import {
CHANNEL_KIND_LABEL,
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
@@ -156,6 +159,10 @@ function NotificationChannels(): JSX.Element {
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
// With a kind picked, creating one of that kind is the likely next step, so
// the button offers it rather than making the type be chosen again.
const filteredKind =
kind !== KIND_FILTER_ALL ? (kind as ChannelKind) : undefined;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
@@ -169,27 +176,38 @@ function NotificationChannels(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All notification channels
{filteredKind
? `All ${CHANNEL_KIND_LABEL[filteredKind]} channels`
: 'All notification channels'}
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
{!isEmpty && (
<ColumnSelector
columns={columnsWithActions}
storageKey="notification-channels-columns"
testId="channels-columns"
/>
)}
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => formView.openCreate(filteredKind)}
testId="channels-create"
>
{filteredKind
? `New ${CHANNEL_KIND_LABEL[filteredKind]} Channel`
: 'New Channel'}
</AuthZButton>
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</AuthZButton>
</div>
<div className={styles.filtersRow}>
@@ -215,12 +233,8 @@ function NotificationChannels(): JSX.Element {
</header>
<div ref={containerRef} className={styles.tableContainer}>
{/* Fails closed, unlike the guard's default: the fetch is gated on the
same check, so falling open would render an empty table shell with
no reason given. The header and filters stay, as on dashboards. */}
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): JSX.Element => (
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
@@ -245,6 +259,10 @@ function NotificationChannels(): JSX.Element {
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmpty ? (
<ChannelsEmptyState
onCreate={(): void => formView.openCreate(filteredKind)}
/>
) : (
<TanStackTable<Channel>
data={channels}

View File

@@ -72,6 +72,52 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends no telegram message, so the backend keeps its own template',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Telegram$/ }));
await user.type(
screen.getByTestId('channel-name-textbox'),
'telegram alerts',
);
await user.type(screen.getByTestId('telegram-bot-token-textbox'), '123:abc');
await user.type(screen.getByTestId('telegram-chat-id-textbox'), '-100123');
// the box is empty on screen, and an empty box must not become an empty
// string in the request: that would overwrite the backend's default
expect(screen.getByTestId('telegram-message-textarea')).toHaveValue('');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
const spec = (body as { config: { spec: Record<string, unknown> } }).config
.spec;
expect(spec).not.toHaveProperty('message');
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -162,15 +208,20 @@ describe('ChannelForm save', () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// Save is disabled while the checks are in flight and settles once they
// resolve, so waiting on it means the failure has landed
const save = await screen.findByTestId('save-channel-button', {}, FIND);
const channel = await screen.findByTestId('slack-channel-textbox', {}, FIND);
await waitFor(() => expect(channel).toBeEnabled(), FIND);
// Save is off until something changes, so one edit is what tells a
// settled failure apart from a denial
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(channel, '-edited');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
expect(screen.getByTestId('slack-channel-textbox')).toBeEnabled();
expect(
screen.queryByTestId('channel-read-only-notice'),
).not.toBeInTheDocument();
@@ -178,6 +229,69 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps save off until something is edited',
async () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// Save is also off while the permission checks are in flight. Test is
// gated on those checks alone, so it settling means Save is only off
// because nothing has changed.
const testButton = await screen.findByTestId(
'test-channel-button',
{},
FIND,
);
await waitFor(
() => expect(testButton).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
const save = screen.getByTestId('save-channel-button');
expect(save).toHaveAttribute('aria-disabled', 'true');
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
},
TEST_TIMEOUT,
);
it(
'asks before discarding an edited form, and leaves straight away otherwise',
async () => {
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const discard = await screen.findByTestId(
'discard-channel-button',
{},
FIND,
);
await user.click(discard);
expect(onDone).toHaveBeenCalledTimes(1);
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await user.click(discard);
// the test harness stubs `t`, so the modal renders the keys themselves
await screen.findByText('discard_changes_title', {}, FIND);
expect(onDone).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: 'button_discard_confirm' }),
);
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(2));
},
TEST_TIMEOUT,
);
it(
'explains a channel the v2 API cannot model instead of showing a blank form',
async () => {

View File

@@ -6,16 +6,27 @@ import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/pe
import ChannelFormBody from './ChannelFormBody';
import styles from './ChannelForm.module.scss';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useChannelDraft } from './useChannelDraft';
interface ChannelFormProps {
channelId?: string;
/** Preselects the type when creating, so a filtered list can offer its kind. */
initialKind?: ChannelKind;
onDone: () => void;
}
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
function ChannelForm({
channelId,
initialKind,
onDone,
}: ChannelFormProps): JSX.Element {
const { t } = useTranslation('channels');
const { draft, isLoading, loadError } = useChannelDraft(channelId);
const { draft, isLoading, loadError } = useChannelDraft(
channelId,
initialKind,
);
const {
canEdit,

View File

@@ -1,3 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import FormAlertChannels from 'container/FormAlertChannels';
import { ChannelFormState } from 'container/CreateAlertChannels/channelFormValues';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
@@ -24,23 +27,54 @@ function ChannelFormBody({
saveChecks,
testChecks,
}: ChannelFormBodyProps): JSX.Element {
const { form, type, onTypeChange, onSave, onTest, isSaving, isTesting } =
useChannelFormState({ draft, target, onDone });
const { t } = useTranslation('channels');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const {
form,
type,
isDirty,
onTypeChange,
onSave,
onTest,
isSaving,
isTesting,
} = useChannelFormState({ draft, target, onDone });
const onDiscard = (): void => {
if (!isDirty) {
onDone();
return;
}
confirmDelete({
title: (
<Typography.Title level={5}>{t('discard_changes_title')}</Typography.Title>
),
content: t('discard_changes_body'),
confirmText: t('button_discard_confirm'),
onConfirm: onDone,
});
};
return (
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
saveChecks={saveChecks}
testChecks={testChecks}
/>
<>
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
onDiscardHandler={onDiscard}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
hasChanges={isDirty}
saveChecks={saveChecks}
testChecks={testChecks}
/>
{contextHolder}
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
ChannelInitialConfig,
DEFAULT_CHANNEL_KIND,
} from 'container/CreateAlertChannels/defaults';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { toAPIError } from 'utils/errorUtils';
export interface ChannelDraftResult {
@@ -22,7 +23,10 @@ export interface ChannelDraftResult {
* Kept apart from the form so the form is built once its values are known,
* rather than mounted empty and reset.
*/
export function useChannelDraft(channelId?: string): ChannelDraftResult {
export function useChannelDraft(
channelId?: string,
initialKind?: ChannelKind,
): ChannelDraftResult {
const isEditing = !!channelId;
const { data, error: loadFailure } = useGetNotificationChannel(
@@ -34,10 +38,10 @@ export function useChannelDraft(channelId?: string): ChannelDraftResult {
channelId
? null
: {
kind: DEFAULT_CHANNEL_KIND,
kind: initialKind ?? DEFAULT_CHANNEL_KIND,
values: {
sendResolved: true,
...ChannelInitialConfig[DEFAULT_CHANNEL_KIND],
...ChannelInitialConfig[initialKind ?? DEFAULT_CHANNEL_KIND],
},
},
);

View File

@@ -31,6 +31,8 @@ import { toAPIError } from 'utils/errorUtils';
export interface ChannelFormStateResult {
form: UseFormReturn<ChannelFormValues>;
type: ChannelKind;
/** False until something is edited, which is what gates saving. */
isDirty: boolean;
onTypeChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
@@ -84,10 +86,13 @@ export function useChannelFormState({
setTypeState(nextType);
// shouldDirty, because the type lives outside the form: without it
// picking a different type leaves the form pristine and unsavable.
Object.entries(ChannelInitialConfig[nextType]).forEach(([field, value]) => {
form.setValue(
field as keyof ChannelFormValues,
value as ChannelFormValues[keyof ChannelFormValues],
{ shouldDirty: true },
);
});
},
@@ -177,6 +182,7 @@ export function useChannelFormState({
return {
form,
type,
isDirty: form.formState.isDirty,
onTypeChange,
onSave: form.handleSubmit(submitSave),
onTest: form.handleSubmit(submitTest),

View File

@@ -0,0 +1,48 @@
/* The same shape as the page's no-matches state, so the two empty screens read
as the same product rather than two designs. */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
color: var(--l1-foreground);
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
.subtitle {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
max-width: 420px;
}
/* The button sizes to its label here, so "New channel" is never clipped. */
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-1);
}
.docsLink {
color: var(--l2-foreground);
font-size: 13px;
text-decoration: none;
&:hover {
color: var(--l1-foreground);
text-decoration: underline;
}
}

View File

@@ -0,0 +1,54 @@
import { Bell, Plus } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import styles from './ChannelsEmptyState.module.scss';
const CREATE_CHECKS = [NotificationChannelCreatePermission];
const DOCS_URL = 'https://signoz.io/docs/setup-alerts-notification/';
interface ChannelsEmptyStateProps {
onCreate: () => void;
}
function ChannelsEmptyState({
onCreate,
}: ChannelsEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="channels-empty-state">
<Bell className={styles.icon} size={16} />
<Typography.Text className={styles.title}>
No notification channels yet
</Typography.Text>
<Typography.Text className={styles.subtitle}>
A channel is where an alert ends up. Create one and your alert rules and
routing policies can point at it.
</Typography.Text>
<div className={styles.actions}>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={onCreate}
testId="channels-empty-create"
>
New channel
</AuthZButton>
</div>
<a
className={styles.docsLink}
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="channels-empty-docs"
>
Read the setup guide
</a>
</div>
);
}
export default ChannelsEmptyState;

View File

@@ -4,7 +4,7 @@ import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: () => void;
openCreate: (kind?: string) => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
@@ -22,7 +22,10 @@ export function useChannelFormView(): ChannelFormView {
};
return {
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openCreate: (kind?: string): void =>
history.push(
kind ? `${ROUTES.CHANNELS_NEW}?kind=${kind}` : ROUTES.CHANNELS_NEW,
),
openEdit,
};
}

View File

@@ -791,6 +791,22 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
// one of each so the repeating row editors render with content
fields: [
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
{
title: 'Service',
value: '{{ .CommonLabels.service_name }}',
short: false,
},
],
actions: [
{
type: 'button',
text: 'Runbook',
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
},
],
sendResolved: true,
},
webhook: {

View File

@@ -2,12 +2,6 @@
padding: var(--spacing-6);
}
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
}
.crumbName {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
}

View File

@@ -1,6 +1,7 @@
import { Spinner } from '@signozhq/ui/spinner';
import { toast } from '@signozhq/ui/sonner';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import BackHeader from 'components/BackHeader/BackHeader';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
@@ -13,42 +14,45 @@ interface ChannelsEditPageProps {
}
function ChannelsEditPage({ channelId }: ChannelsEditPageProps): JSX.Element {
const { data } = useGetNotificationChannel({ id: channelId });
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{
title: (
<span className={styles.crumbName}>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
</span>
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={ROUTES.ALL_CHANNELS}
testId="channel-edit-header"
title={
isLoading ? (
<Spinner
size={16}
aria-label="Loading channel"
testId="channel-name-loading"
/>
) : (
<>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
</span>
),
isLast: true,
},
]}
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
</>
)
}
/>
<div className={styles.content}>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
);
}

View File

@@ -1,30 +1,46 @@
import { useLocation } from 'react-router-dom';
import { Skeleton } from 'antd';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import BackHeader from 'components/BackHeader/BackHeader';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
import { KIND_KEY } from 'pages/AlertList/NotificationChannels/constants';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
const requested = new URLSearchParams(useLocation().search).get('kind');
// an unknown kind in the URL falls back to the form's own default
const initialKind = (Object.values(ChannelKind) as string[]).includes(
requested ?? '',
)
? (requested as ChannelKind)
: undefined;
// Leaving goes back to the list the way it was left: filtered by the kind
// that opened this form.
const listRoute = initialKind
? `${ROUTES.ALL_CHANNELS}&${KIND_KEY}=${initialKind}`
: ROUTES.ALL_CHANNELS;
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New channel', isLast: true },
]}
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={listRoute}
title="New channel"
testId="channel-new-header"
/>
<div className={styles.content}>
<ChannelForm
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
<ChannelForm
initialKind={initialKind}
onDone={(): void => {
history.replace(listRoute);
}}
/>
</div>
);
}

View File

@@ -2,6 +2,6 @@
padding: var(--spacing-6);
}
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
}

View File

@@ -5,6 +5,7 @@ import cx from 'classnames';
import styles from './CopyButton.module.scss';
import { useCopyButton } from './useCopyButton';
import { Tooltip } from '@signozhq/ui/tooltip';
export interface CopyButtonProps {
/** Text written to the clipboard on click. */
@@ -16,6 +17,8 @@ export interface CopyButtonProps {
testId?: string;
/** Called after the copy is triggered (e.g. to show a toast). */
onCopy?: () => void;
/** Side on which the tooltip should appear. Default "right". */
tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
}
/**
@@ -29,6 +32,7 @@ function CopyButton({
ariaLabel = 'Copy',
testId,
onCopy,
tooltipSide = 'top',
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyButton();
@@ -44,20 +48,27 @@ function CopyButton({
const stackStyle: CSSProperties = { width: size, height: size };
return (
<Button
variant="ghost"
color="secondary"
size="sm"
icon
onClick={handleClick}
aria-label={isCopied ? 'Copied' : ariaLabel}
testId={testId}
>
<span className={styles.iconStack} style={stackStyle} data-copied={isCopied}>
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
</span>
</Button>
// TODO: remove wrapper once Button supports tooltip
<Tooltip title={isCopied ? 'Copied' : ariaLabel} side={tooltipSide}>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
onClick={handleClick}
aria-label={isCopied ? 'Copied' : ariaLabel}
testId={testId}
>
<span
className={styles.iconStack}
style={stackStyle}
data-copied={isCopied}
>
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
</span>
</Button>
</Tooltip>
);
}

View File

@@ -9,8 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
"github.com/uptrace/bun"
)
type config struct {
@@ -239,37 +237,6 @@ func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.
return channels, nil
}
func (store *config) GetMatchers(ctx context.Context, orgID string) (map[string][]string, error) {
type matcher struct {
bun.BaseModel `bun:"table:rule"`
ID valuer.UUID `bun:"id,pk"`
Data string `bun:"data"`
}
matchers := []matcher{}
err := store.
sqlstore.
BunDB().
NewSelect().
Column("id", "data").
Model(&matchers).
Scan(ctx)
if err != nil {
return nil, err
}
matchersMap := make(map[string][]string)
for _, matcher := range matchers {
receivers := gjson.Get(matcher.Data, "preferredChannels").Array()
for _, receiver := range receivers {
matchersMap[matcher.ID.StringValue()] = append(matchersMap[matcher.ID.StringValue()], receiver.String())
}
}
return matchersMap, nil
}
func (store *config) wrap(ctx context.Context, fn func(ctx context.Context) error, opts ...alertmanagertypes.StoreOption) error {
storeOpts := alertmanagertypes.NewStoreOptions(opts...)

View File

@@ -245,25 +245,13 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
return nil, err
}
matchers, err := service.configStore.GetMatchers(ctx, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
config, err := alertmanagertypes.NewConfigFromChannels(service.config.Global, service.config.Route, channels, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
for ruleID, receivers := range matchers {
err = config.CreateRuleIDMatcher(ruleID, receivers)
if err != nil {
return nil, err
}
}
if incomingConfig.StoreableConfig().Hash != config.StoreableConfig().Hash {
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels")
return config, nil
}

View File

@@ -11,20 +11,6 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},

View File

@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Summary: "Metric anomaly rule",
Description: "Wraps a builder query in the `anomaly` function with daily seasonality, so SigNoz compares each point against the forecast for that time of day. The threshold is compared against the signed anomaly score, so `below` with a target of -2 fires when an abrupt drop holds for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"op": "below",
"matchType": "all_the_times",
"target": 2,
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "below",
"matchType": "all_the_times",
"target": -2,
"channels": []any{"slack-ingestion"},
},
},
},
},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"evaluation": rolling("24h", "3h"),
"notificationSettings": map[string]any{
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "warning"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",

View File

@@ -49,19 +49,14 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
builder := &relatedLinkBuilder{alertType: rule.AlertType}
if rule.Evaluation != nil {
if evaluation, err := rule.Evaluation.GetEvaluation(); err == nil {
builder.evaluation = evaluation
}
if rule.Evaluation == nil {
return nil
}
if builder.evaluation == nil {
evalWindow := rule.EvalWindow
if evalWindow.IsZero() {
evalWindow = valuer.MustParseTextDuration("5m")
}
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
evaluation, err := rule.Evaluation.GetEvaluation()
if err != nil {
return nil
}
builder := &relatedLinkBuilder{alertType: rule.AlertType, evaluation: evaluation}
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)

View File

@@ -3,10 +3,8 @@ package app
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
@@ -360,14 +358,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/explorer/views", am.ViewAccess(aH.Signoz.Handlers.SavedView.List)).Methods(http.MethodGet)
@@ -496,166 +486,6 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
return nil
}
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
rules, err := aH.ruleManager.ListRuleStates(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// todo(amol): need to add sorter
aH.Respond(w, rules)
}
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, ruleResponse)
}
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, rule)
}
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully edited")
}
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
err := aH.ruleManager.DeleteRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully deleted")
}
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, gettableRule)
}
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
response := map[string]interface{}{
"alertCount": alertCount,
"message": "notification sent",
}
aH.Respond(w, response)
}
func prepareQuery(r *http.Request) (string, error) {
var postData *model.DashboardVars

View File

@@ -34,10 +34,6 @@ type BaseRule struct {
ruleCondition *ruletypes.RuleCondition
Threshold ruletypes.RuleThreshold
// evalWindow is the time window used for evaluating the rule
// i.e. each time we lookback from the current time, we look at data for the last
// evalWindow duration
evalWindow valuer.TextDuration
// holdDuration is the duration for which the alert waits before firing
holdDuration valuer.TextDuration
@@ -49,10 +45,7 @@ type BaseRule struct {
// these are the same for all alerts created for this rule
labels ruletypes.Labels
annotations ruletypes.Labels
// preferredChannels is the list of channels to send the alert to
// if the rule is triggered
preferredChannels []string
mtx sync.Mutex
mtx sync.Mutex
// the time it took to evaluate the rule (most recent evaluation)
evaluationDuration time.Duration
// the timestamp of the last evaluation
@@ -157,20 +150,18 @@ func NewBaseRule(
}
baseRule := &BaseRule{
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
preferredChannels: p.PreferredChannels,
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
}
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
@@ -178,10 +169,6 @@ func NewBaseRule(
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
}
if baseRule.evalWindow.IsZero() {
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
}
for _, opt := range opts {
opt(baseRule)
}
@@ -232,10 +219,6 @@ func (r *BaseRule) EvalDelay() valuer.TextDuration {
return r.evalDelay
}
func (r *BaseRule) EvalWindow() valuer.TextDuration {
return r.evalWindow
}
func (r *BaseRule) HoldDuration() valuer.TextDuration {
return r.holdDuration
}
@@ -246,7 +229,6 @@ func (r *BaseRule) Name() string { return r.name }
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
func (r *BaseRule) GeneratorURL() string {
params := url.Values{}

View File

@@ -361,7 +361,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
if err != nil {
return err
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
}
if err := parsedRule.Validate(); err != nil {
return err
@@ -548,7 +548,7 @@ func (m *Manager) deleteTask(taskName string) {
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
parsedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
return nil, err
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
}
return m.createRule(ctx, parsedRule, ruleStr)
@@ -1051,7 +1051,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
}
if err := storedRule.Validate(); err != nil {
return nil, err

View File

@@ -49,13 +49,12 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
PanelType: ruletypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -108,12 +107,10 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
PanelType: ruletypes.PanelTypeGraph,
@@ -138,7 +135,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},

View File

@@ -356,12 +356,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *PromRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -690,9 +690,6 @@ func TestPromRuleEval(t *testing.T) {
logger := instrumentationtest.New().Logger()
for idx, c := range cases {
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -905,11 +902,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1198,11 +1191,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1269,8 +1258,6 @@ func TestPromRule_NoData(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1365,10 +1352,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1491,8 +1475,6 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1562,9 +1544,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
for _, c := range cases {
rc := postableRule.RuleCondition
rc.Target = &targetForNoAlert
target := &targetForNoAlert
if c.alertCondition {
rc.Target = &targetForAlert
target = &targetForAlert
}
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
@@ -1573,9 +1555,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName.StringValue(),
TargetValue: rc.Target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
TargetValue: target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -20,15 +20,12 @@ type Rule interface {
Annotations() ruletypes.Labels
Condition() *ruletypes.RuleCondition
EvalDelay() valuer.TextDuration
EvalWindow() valuer.TextDuration
HoldDuration() valuer.TextDuration
State() ruletypes.AlertState
ActiveAlerts() []*ruletypes.Alert
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
ActiveAlertsLabelFP() map[uint64]struct{}
PreferredChannels() []string
// Eval evaluates the rule at the given timestamp and returns the number of active alerts.
Eval(context.Context, time.Time) (int, error)
String() string

View File

@@ -61,16 +61,16 @@ func (r *ThresholdRule) Type() ruletypes.RuleType {
}
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request v5",
slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
slog.Int64("eval_window", end-start),
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -465,12 +465,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *ThresholdRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -171,10 +171,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -183,9 +180,9 @@ func TestPrepareParamsForLogs(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -239,10 +236,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -252,9 +246,9 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -304,10 +298,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -317,9 +308,9 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -366,10 +357,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -378,9 +366,9 @@ func TestPrepareParamsForTraces(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -653,11 +641,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
ExpectQuery(queryString).
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -867,11 +851,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -975,11 +955,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1083,11 +1059,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1256,11 +1228,7 @@ func TestMultipleThresholdRule(t *testing.T) {
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1930,8 +1898,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -2050,7 +2016,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
rc := postableRule.RuleCondition
rc.Target = &c.target
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
rc.Thresholds = &ruletypes.RuleThresholdData{
@@ -2059,8 +2024,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
{
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -3,7 +3,6 @@ package ruler
import "net/http"
type Handler interface {
ListRules(http.ResponseWriter, *http.Request)
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)

View File

@@ -25,24 +25,6 @@ func NewHandler(ruler ruler.Ruler) ruler.Handler {
return &handler{ruler: ruler}
}
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
rules, err := handler.ruler.ListRuleStates(ctx)
if err != nil {
render.Error(rw, err)
return
}
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
for _, rule := range rules.Rules {
view = append(view, ruletypes.NewRule(rule))
}
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -264,6 +264,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
)
}

View File

@@ -0,0 +1,475 @@
package sqlmigration
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"time"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
const (
schemaVersionV2Alpha1 = "v2alpha1"
ruleTypeThreshold = "threshold_rule"
ruleTypeProm = "promql_rule"
ruleTypeAnomaly = "anomaly_rule"
queryTypeBuilder = "builder"
queryTypePromQL = "promql"
compareOperatorBelow = "2"
criticalThresholdName = "critical"
labelSeverity = "severity"
)
// The values the threshold decoder accepts; a stored value outside them fails
// every later read of the migrated row.
var (
supportedCompareOperators = []string{"1", "above", ">", "2", "below", "<", "3", "equal", "eq", "=", "4", "not_equal", "not_eq", "!=", "5", "above_or_equal", "above_or_eq", ">=", "6", "below_or_equal", "below_or_eq", "<=", "7", "outside_bounds"}
supportedMatchTypes = []string{"1", "at_least_once", "2", "all_the_times", "3", "on_average", "avg", "4", "in_total", "sum", "5", "last"}
)
type migrateRulesV1ToV2Alpha1 struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMigrateRulesV1ToV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_rules_v1_to_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRulesV1ToV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up rewrites every v1 rule in every org as v2alpha1 in place. A row that does
// not decode, or a v1 rule that cannot be rewritten, is logged and left as is;
// a failed write aborts the run and rolls back every org.
func (migration *migrateRulesV1ToV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, orgID := range orgIDs {
if err := migration.convertOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) convertOrg(ctx context.Context, orgID string) error {
db := migration.sqlstore.BunDBCtx(ctx)
rules := make([]*ruleRow, 0)
if err := db.NewSelect().Model(&rules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
routes := make([]*routePolicyRow, 0)
if err := db.NewSelect().Model(&routes).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
routesByName := make(map[string][]*routePolicyRow)
for _, route := range routes {
routesByName[route.Name] = append(routesByName[route.Name], route)
}
var migrated, skipped, undecodable, unmigratable int
for _, stored := range rules {
var header struct {
SchemaVersion string `json:"schemaVersion"`
}
if err := json.Unmarshal([]byte(stored.Data), &header); err != nil {
undecodable++
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
if header.SchemaVersion == schemaVersionV2Alpha1 {
skipped++
continue
}
rule := new(postableRule)
if err := json.Unmarshal([]byte(stored.Data), rule); err != nil {
undecodable++
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
routes := routesByName[stored.ID]
if err := validateRoutes(routes, rule.PreferredChannels); err != nil {
unmigratable++
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
var route *routePolicyRow
expression := ""
if len(routes) == 1 {
route = routes[0]
expression = route.Expression
}
if err := convertRule(stored.ID, rule, route); err != nil {
unmigratable++
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
continue
}
data, err := marshalUnescaped(rule)
if err != nil {
return err
}
stored.Data = string(data)
if _, err := db.NewUpdate().Model(stored).Column("data").WherePK().Exec(ctx); err != nil {
return err
}
if route != nil && route.Expression != expression {
if _, err := db.NewUpdate().Model(route).Column("expression").WherePK().Exec(ctx); err != nil {
return err
}
}
migrated++
}
migration.logger.InfoContext(ctx, "migrated rules from v1 to v2alpha1", slog.String("org_id", orgID), slog.Int("total", len(rules)), slog.Int("migrated", migrated), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
return nil
}
// convertRule rewrites rule in place, in the order the v1 decode derived the
// same values. Version and source are left as stored. A nil route is a rule
// that never had channels.
func convertRule(ruleID string, rule *postableRule, route *routePolicyRow) error {
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition or condition.compositeQuery is null")
}
if err := rule.validateThreshold(); err != nil {
return err
}
channels := []string{}
if route != nil {
channels = route.Channels
}
rule.fillDurationDefaults()
if err := rule.validateDurations(); err != nil {
return err
}
rule.deriveRuleType()
rule.negateAnomalyBelowTarget()
rule.repairEmptySeverity(ruleID, route)
rule.relocateThreshold(channels)
rule.relocateEvaluation()
rule.synthesizeNotificationSettings()
rule.SchemaVersion = schemaVersionV2Alpha1
rule.clearV1Fields()
return nil
}
// validateRoutes checks the rule's route_policy rows, named after the rule. A
// rule that never had channels got no row from migration 049 and migrates with
// none; a rule with preferred channels but no row would lose them, so it is
// left at v1.
func validateRoutes(routes []*routePolicyRow, preferredChannels []string) error {
if len(routes) > 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected one route_policy row, found %d", len(routes))
}
if len(routes) == 0 && len(preferredChannels) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
}
return nil
}
type ruleRow struct {
bun.BaseModel `bun:"table:rule"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Data string `bun:"data"`
}
type routePolicyRow struct {
bun.BaseModel `bun:"table:route_policy"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Name string `bun:"name"`
Expression string `bun:"expression"`
Channels []string `bun:"channels,type:jsonb"`
}
// postableRule mirrors PostableRule key for key, in its order. Keys the
// crosswalk reads or writes are typed; the rest pass through as stored.
type postableRule struct {
AlertName json.RawMessage `json:"alert,omitempty"`
AlertType json.RawMessage `json:"alertType,omitempty"`
Description json.RawMessage `json:"description,omitempty"`
RuleType string `json:"ruleType"`
EvalWindow textDuration `json:"evalWindow,omitzero"`
Frequency textDuration `json:"frequency,omitzero"`
RuleCondition *ruleCondition `json:"condition"`
Labels map[string]string `json:"labels,omitempty"`
Annotations json.RawMessage `json:"annotations,omitempty"`
Disabled json.RawMessage `json:"disabled,omitempty"`
Source json.RawMessage `json:"source,omitempty"`
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version json.RawMessage `json:"version,omitempty"`
Evaluation *evaluationEnvelope `json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *notificationSettings `json:"notificationSettings,omitempty"`
}
type ruleCondition struct {
CompositeQuery *compositeQuery `json:"compositeQuery"`
CompareOperator string `json:"op,omitempty"`
Target *float64 `json:"target,omitempty"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor json.RawMessage `json:"absentFor,omitempty"`
MatchType string `json:"matchType,omitempty"`
TargetUnit string `json:"targetUnit,omitempty"`
Algorithm json.RawMessage `json:"algorithm,omitempty"`
Seasonality json.RawMessage `json:"seasonality,omitempty"`
SelectedQuery json.RawMessage `json:"selectedQueryName,omitempty"`
RequireMinPoints json.RawMessage `json:"requireMinPoints,omitempty"`
RequiredNumPoints json.RawMessage `json:"requiredNumPoints,omitempty"`
Thresholds *ruleThresholds `json:"thresholds,omitempty"`
}
type compositeQuery struct {
Queries json.RawMessage `json:"queries,omitempty"`
PanelType json.RawMessage `json:"panelType,omitempty"`
QueryType string `json:"queryType"`
Unit json.RawMessage `json:"unit,omitempty"`
}
type ruleThresholds struct {
Kind string `json:"kind"`
Spec []basicThreshold `json:"spec"`
}
type basicThreshold struct {
Name string `json:"name"`
TargetValue *float64 `json:"target"`
TargetUnit string `json:"targetUnit"`
RecoveryTarget *float64 `json:"recoveryTarget"`
MatchType string `json:"matchType"`
CompareOperator string `json:"op"`
Channels []string `json:"channels"`
}
type evaluationEnvelope struct {
Kind string `json:"kind"`
Spec rollingWindow `json:"spec"`
}
type rollingWindow struct {
EvalWindow textDuration `json:"evalWindow"`
Frequency textDuration `json:"frequency"`
}
type notificationSettings struct {
Renotify *renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy"`
}
type renotify struct {
Enabled bool `json:"enabled"`
ReNotifyInterval textDuration `json:"interval,omitzero"`
AlertStates []string `json:"alertStates,omitzero"`
}
// validateThreshold rejects what the v1 write path once let through: a
// missing target, or an op or matchType the threshold decoder does not
// accept. Each fails every later read of the migrated row.
func (rule *postableRule) validateThreshold() error {
condition := rule.RuleCondition
if condition.Target == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.target is null")
}
if !slices.Contains(supportedCompareOperators, condition.CompareOperator) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q", condition.CompareOperator)
}
if !slices.Contains(supportedMatchTypes, condition.MatchType) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q", condition.MatchType)
}
return nil
}
func (rule *postableRule) fillDurationDefaults() {
if rule.EvalWindow.IsZero() {
rule.EvalWindow = mustParseTextDuration("5m")
}
if rule.Frequency.IsZero() {
rule.Frequency = mustParseTextDuration("1m")
}
}
// validateDurations rejects what the v1 write path never checked: a negative
// evalWindow or frequency, which no read of a rolling window accepts.
func (rule *postableRule) validateDurations() error {
if !rule.EvalWindow.IsPositive() || !rule.Frequency.IsPositive() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow %s or frequency %s is negative", rule.EvalWindow, rule.Frequency)
}
return nil
}
func (rule *postableRule) deriveRuleType() {
switch rule.RuleCondition.CompositeQuery.QueryType {
case queryTypeBuilder:
if rule.RuleType == "" {
rule.RuleType = ruleTypeThreshold
}
case queryTypePromQL:
rule.RuleType = ruleTypeProm
}
}
// negateAnomalyBelowTarget stores "below 3" as -3, since anomaly scores are
// signed. Only the raw "2" negates; "below" and "<" keep the sign the rule
// evaluates with today.
func (rule *postableRule) negateAnomalyBelowTarget() {
condition := rule.RuleCondition
if rule.RuleType != ruleTypeAnomaly || condition.CompareOperator != compareOperatorBelow || condition.Target == nil {
return
}
negated := -*condition.Target
condition.Target = &negated
}
// repairEmptySeverity rewrites a severity of "" as critical in labels and in
// the route expression that matches alerts on it, since a threshold named ""
// fails every later read.
func (rule *postableRule) repairEmptySeverity(ruleID string, route *routePolicyRow) {
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
return
}
rule.Labels[labelSeverity] = criticalThresholdName
if route != nil {
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
}
}
func (rule *postableRule) relocateThreshold(channels []string) {
thresholdName := criticalThresholdName
if severity, ok := rule.Labels[labelSeverity]; ok {
thresholdName = severity
}
condition := rule.RuleCondition
condition.Thresholds = &ruleThresholds{
Kind: "basic",
Spec: []basicThreshold{{
Name: thresholdName,
TargetValue: condition.Target,
TargetUnit: condition.TargetUnit,
MatchType: condition.MatchType,
CompareOperator: condition.CompareOperator,
Channels: channels,
}},
}
}
func (rule *postableRule) relocateEvaluation() {
rule.Evaluation = &evaluationEnvelope{
Kind: "rolling",
Spec: rollingWindow{EvalWindow: rule.EvalWindow, Frequency: rule.Frequency},
}
}
func (rule *postableRule) synthesizeNotificationSettings() {
alertStates := []string{"firing"}
if rule.RuleCondition.AlertOnAbsent {
alertStates = append(alertStates, "nodata")
}
rule.NotificationSettings = &notificationSettings{
Renotify: &renotify{
Enabled: true,
ReNotifyInterval: mustParseTextDuration("4h"),
AlertStates: alertStates,
},
UsePolicy: false,
}
}
func (rule *postableRule) clearV1Fields() {
rule.EvalWindow = textDuration{}
rule.Frequency = textDuration{}
rule.PreferredChannels = nil
rule.RuleCondition.Target = nil
rule.RuleCondition.CompareOperator = ""
rule.RuleCondition.MatchType = ""
rule.RuleCondition.TargetUnit = ""
}
// textDuration decodes a duration the way the app's TextDuration does, a
// string or a number of nanoseconds, and writes a string back in its stored
// spelling.
type textDuration struct {
text string
value time.Duration
}
func mustParseTextDuration(text string) textDuration {
value, err := time.ParseDuration(text)
if err != nil {
panic(err)
}
return textDuration{text: text, value: value}
}
func (d textDuration) IsZero() bool {
return d.value == 0
}
func (d textDuration) IsPositive() bool {
return d.value > 0
}
func (d textDuration) String() string {
if d.text != "" {
return d.text
}
return d.value.String()
}
func (d textDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *textDuration) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch value := raw.(type) {
case float64:
*d = textDuration{value: time.Duration(value)}
return nil
case string:
parsed, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = textDuration{text: value, value: parsed}
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid duration %s", data)
}
}
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -619,10 +619,6 @@ type ConfigStore interface {
// ListAllChannels returns the list of channels for all organizations.
ListAllChannels(context.Context) ([]*Channel, error)
// GetMatchers gets a list of matchers per organization.
// Matchers is an array of ruleId to receiver names.
GetMatchers(context.Context, string) (map[string][]string, error)
}
// MarshalSecretValue if set to true will expose Secret type

View File

@@ -123,22 +123,10 @@ func (d *DashboardSpec) validatePanels() error {
return err
}
}
// validatePanelQueryCount left exactly one query for a spec that reads it.
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
return err
}
}
}
return nil
}
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
// query. It is checked after the query itself validates.
type queryBoundPanelSpec interface {
validateAgainstQuery(query Query, path string) error
}
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
if queries == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)

View File

@@ -1870,319 +1870,6 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
}
}
func TestScatterPlotPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
}
func TestValidateScatterPlotDashboard(t *testing.T) {
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
require.NoError(t, err, "reading example file")
spec, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
softMin, softMax := 0.0, 1000.0
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
assert.Equal(t, &ScatterPlotPanelSpec{
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
Axes: ScatterPlotAxes{
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
},
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
Thresholds: []ScatterPlotThreshold{
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
},
}, panelSpec)
dashboard := &DashboardV2{Spec: *spec}
request, err := dashboard.GetPanelQuery(1, 2, "p1")
require.NoError(t, err, "building the panel's query failed")
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
require.NotNil(t, request.FormatOptions)
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
require.Len(t, request.CompositeQuery.Queries, 1)
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected a traces builder query")
assert.Len(t, query.Aggregations, 3)
assert.Len(t, query.GroupBy, 2)
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
// the panel read back out of storage draws the same scatter plot
stored, err := json.Marshal(spec)
require.NoError(t, err, "marshal dashboard failed")
reread, err := unmarshalDashboard(stored)
require.NoError(t, err, "the stored dashboard does not validate")
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
require.NoError(t, err, "building the stored panel's query failed")
assert.Equal(t, request, rereadRequest)
}
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
},
{
scenario: "non-string axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"y": {"scale": true}}}`,
expectedErrorSubstring: "cannot unmarshal bool",
},
{
scenario: "isLogScale on a scatter plot axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "single-axis axes shape on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"softMin": 0}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "unknown threshold axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
},
{
scenario: "unknown dimension",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"z": "A"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "point size below the minimum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
},
{
scenario: "max point size above the maximum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
expectedErrorSubstring: "invalid point diameter 41",
},
{
scenario: "fractional point size",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
expectedErrorSubstring: "cannot unmarshal number 4.5",
},
{
scenario: "min point size above max",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
},
{
scenario: "point opacity below the floor",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
},
{
scenario: "point opacity as a percentage",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
expectedErrorSubstring: "invalid point opacity 70",
},
{
scenario: "area fill fields on a scatter plot",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "a single colour key as a string",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "panel-wide unit on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"formatting": {"unit": "ms"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "threshold without a color",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 300}]}`,
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
},
{
scenario: "scale on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"axes": {"scale": "log"}}`,
expectedErrorSubstring: "unknown field",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
tests := []struct {
scenario string
queryPlugin string
dimensions string
expectedErrorSubstring string
}{
{
scenario: "a single-aggregation query is one value column keyed by its name",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
},
{
scenario: "a multi-aggregation query keys each value column by name and expression",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
dimensions: `{"x": "A"}`,
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
},
{
scenario: "a value column of a query the panel does not have",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
dimensions: `{"y": "B"}`,
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
},
{
scenario: "a colour key the query does not group by",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
},
{
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
]}}`,
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
},
{
scenario: "a disabled query yields no value column",
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
]}}`,
dimensions: `{"sizeBy": "A"}`,
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
},
{
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
if test.expectedErrorSubstring == "" {
require.NoError(t, err)
return
}
require.Error(t, err, "expected the binding to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
@@ -2541,7 +2228,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
switch panelKind {
case "signoz/ListPanel":
return "raw"
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
return "scalar"
default:
return "time_series"
@@ -2585,17 +2272,14 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
}

View File

@@ -28,17 +28,16 @@ type PanelPlugin struct {
// (and strips the duplicate parent properties) after reflection.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
})
}
@@ -72,7 +71,6 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
}
}
@@ -236,17 +234,16 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
@@ -263,17 +260,16 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
}
)

View File

@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FillGaps: fillGaps,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
},
}, nil
}

View File

@@ -251,38 +251,34 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
})
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
t.Run(string(kind), func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: kind},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTable},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {

View File

@@ -2,8 +2,6 @@ package dashboardtypes
import (
"encoding/json"
"maps"
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
@@ -189,21 +187,20 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -353,95 +350,6 @@ type TextPanelSpec struct {
HeaderOptions HeaderOptions `json:"headerOptions"`
}
type ScatterPlotPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Dimensions ScatterPlotDimensions `json:"dimensions"`
Formatting TableFormatting `json:"formatting"`
Axes ScatterPlotAxes `json:"axes"`
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
Legend Legend `json:"legend"`
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
}
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
}
keys, known := composite.CollectResultColumnKeys()
if !known {
return nil
}
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
// Empty is unset: the renderer picks a column or a fixed size.
if binding.key == "" {
continue
}
if _, ok := keys.Aggregations[binding.key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
}
}
for _, label := range s.Dimensions.ColorBy {
if _, ok := keys.Groups[label]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
}
}
return nil
}
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
// use the Table column key, so formatting.columnUnits carries over between the
// two kinds.
type ScatterPlotDimensions struct {
X string `json:"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."`
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
ColorBy []string `json:"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."`
}
type ScatterPlotAxes struct {
X ScatterPlotAxis `json:"x"`
Y ScatterPlotAxis `json:"y"`
}
type ScatterPlotAxis struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
Scale AxisScale `json:"scale"`
Label string `json:"label" description:"Axis title. Empty draws none."`
}
type ScatterPlotChartAppearance struct {
Points ScatterPlotPoints `json:"points"`
}
// ScatterPlotPoints keeps the fixed size and the size range side by side so
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
// fields resolve to the renderer default.
type ScatterPlotPoints struct {
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
Opacity *PointOpacity `json:"opacity"`
}
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
type alias ScatterPlotPoints
var tmp alias
if err := json.Unmarshal(data, &tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
}
*p = ScatterPlotPoints(tmp)
return p.validate()
}
func (p ScatterPlotPoints) validate() error {
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
}
return nil
}
type TextPresentation struct {
TextAlign TextAlign `json:"textAlign"`
VerticalAlign VerticalAlign `json:"verticalAlign"`
@@ -523,11 +431,6 @@ type ComparisonThreshold struct {
Format ThresholdFormat `json:"format"`
}
type ScatterPlotThreshold struct {
ThresholdWithLabel
Axis ThresholdAxis `json:"axis"`
}
type TableThreshold struct {
ComparisonThreshold
ColumnName string `json:"columnName" validate:"required" required:"true"`
@@ -813,86 +716,6 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
}
}
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
// defined at and below zero.
type AxisScale struct{ valuer.String }
var (
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
AxisScaleLog = AxisScale{valuer.NewString("log")}
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
)
func (AxisScale) Enum() []any {
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
}
func (as AxisScale) ValueOrDefault() string {
if as.IsZero() {
return AxisScaleAuto.StringValue()
}
return as.StringValue()
}
func (as AxisScale) MarshalJSON() ([]byte, error) {
return json.Marshal(as.ValueOrDefault())
}
func (as *AxisScale) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
}
val := AxisScale{valuer.NewString(v)}
switch val {
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
*as = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
}
}
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
// horizontally, `x` vertically.
type ThresholdAxis struct{ valuer.String }
var (
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
)
func (ThresholdAxis) Enum() []any {
return []any{ThresholdAxisX, ThresholdAxisY}
}
func (ta ThresholdAxis) ValueOrDefault() string {
if ta.IsZero() {
return ThresholdAxisY.StringValue()
}
return ta.StringValue()
}
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
return json.Marshal(ta.ValueOrDefault())
}
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
}
val := ThresholdAxis{valuer.NewString(v)}
switch val {
case ThresholdAxisX, ThresholdAxisY:
*ta = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
}
}
type FillMode struct{ valuer.String }
var (
@@ -1031,53 +854,6 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
return nil
}
const (
minPointDiameter = 2
maxPointDiameter = 40
)
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
type PointDiameter int
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
return nil
}
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
var v int
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
}
if v < minPointDiameter || v > maxPointDiameter {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
}
*d = PointDiameter(v)
return nil
}
const minPointOpacity = 0.1
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
type PointOpacity float64
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointOpacity).WithMaximum(1)
return nil
}
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
}
if v < minPointOpacity || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
}
*o = PointOpacity(v)
return nil
}
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`

View File

@@ -1,134 +0,0 @@
{
"display": {
"name": "service health",
"description": "throughput against tail latency per pod"
},
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"display": {
"name": "throughput vs p99",
"description": ""
},
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {
"visualization": {
"timePreference": "last_1_hr"
},
"dimensions": {
"x": "A.count()",
"y": "A.p99(duration_nano)",
"sizeBy": "A.countIf(has_error = true)",
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
},
"formatting": {
"columnUnits": {
"A.p99(duration_nano)": "ns"
},
"decimalPrecision": "1"
},
"axes": {
"x": {
"softMin": 0,
"softMax": null,
"scale": "log",
"label": "Throughput"
},
"y": {
"softMin": null,
"softMax": 1000,
"scale": "symlog",
"label": "p99 latency"
}
},
"chartAppearance": {
"points": {
"size": 8,
"minSize": 4,
"maxSize": 24,
"opacity": 0.1
}
},
"legend": {
"position": "right",
"mode": "list",
"customColors": {
"checkout": "#1f77b4"
}
},
"thresholds": [
{
"value": 300,
"unit": "ms",
"color": "#f00",
"label": "p99 SLO",
"axis": "y"
},
{
"value": 50,
"color": "#0f0",
"axis": "x"
}
]
}
},
"links": [],
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count()"
},
{
"expression": "p99(duration_nano)"
},
{
"expression": "countIf(has_error = true)"
}
],
"groupBy": [
{
"name": "k8s.namespace.name"
},
{
"name": "k8s.pod.name"
}
]
}
}
}
}
]
}
}
},
"links": [],
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"content": {
"$ref": "#/spec/panels/p1"
}
}
]
}
}
]
}

View File

@@ -266,43 +266,6 @@ type CompositeQuery struct {
Queries []QueryEnvelope `json:"queries"`
}
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
// UI keys columns: an aggregation column is the query name, or
// queryName.expression when the query has several aggregations; a group
// column is a group-by key name.
type ResultColumnKeys struct {
Aggregations map[string]struct{}
Groups map[string]struct{}
}
// CollectResultColumnKeys reports false when a query's columns are only known
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
// aggregation column but keeps its group columns, since a formula over it
// still carries them.
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
for _, envelope := range c.Queries {
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
return ResultColumnKeys{}, false
}
for _, key := range envelope.GetGroupBy() {
keys.Groups[key.Name] = struct{}{}
}
if envelope.IsDisabled() {
continue
}
expressions := envelope.GetAggregationExpressions()
if len(expressions) <= 1 {
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
continue
}
for _, expression := range expressions {
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
}
}
return keys, true
}
// PrepareJSONSchema adds description to the CompositeQuery schema.
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")

View File

@@ -225,34 +225,6 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
return nil
}
// GetAggregationExpressions returns one entry per aggregation. A metric
// aggregation has no expression, so it contributes an empty entry.
func (q *QueryEnvelope) GetAggregationExpressions() []string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[TraceAggregation]:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[LogAggregation]:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[MetricAggregation]:
return make([]string, len(spec.Aggregations))
}
return nil
}
// GetFilter returns the filter.
func (q *QueryEnvelope) GetFilter() *Filter {
switch spec := q.Spec.(type) {

View File

@@ -1903,63 +1903,3 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
testCases := []struct {
scenario string
composite CompositeQuery
expectedKeys ResultColumnKeys
expectedKnown bool
}{
{
scenario: "a single aggregation is keyed by the query name",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Aggregations: []TraceAggregation{{Expression: "count()"}},
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
}}}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
expectedKnown: true,
},
{
scenario: "several aggregations are keyed by query name and expression",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
}}}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
expectedKnown: true,
},
{
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
composite: CompositeQuery{Queries: []QueryEnvelope{
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
}},
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
expectedKnown: true,
},
{
scenario: "clickhouse columns are only known at run time",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
Name: "A",
Query: "SELECT 1",
}}}},
expectedKeys: ResultColumnKeys{},
expectedKnown: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
keys, known := testCase.composite.CollectResultColumnKeys()
assert.Equal(t, testCase.expectedKnown, known)
assert.Equal(t, testCase.expectedKeys, keys)
})
}
}

View File

@@ -32,5 +32,10 @@ func (q *Query) Validate() error {
if q.Order != qbtypes.OrderDirectionAsc && q.Order != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order must be asc or desc")
}
if !q.State.IsZero() {
if err := q.State.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -1,6 +1,9 @@
package ruletypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AlertState struct {
valuer.String
@@ -26,6 +29,15 @@ func (AlertState) Enum() []any {
}
}
func (a AlertState) Validate() error {
switch a {
case StateInactive, StatePending, StateRecovering, StateFiring, StateNoData, StateDisabled:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "state: unsupported value %q; must be one of inactive, pending, recovering, firing, nodata, disabled", a.StringValue())
}
}
var alertStateSeverity = map[AlertState]int{
StateInactive: 0,
StatePending: 1,

View File

@@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -77,6 +78,15 @@ func (PanelType) Enum() []any {
}
}
func (p PanelType) Validate() error {
switch p {
case PanelTypeValue, PanelTypeTable, PanelTypeGraph:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.panelType: unsupported value %q; must be one of value, table, graph", p.StringValue())
}
}
// Note: this is used to represent the state of the alert query
// i.e the active tab which should be used to represent the selection
@@ -99,6 +109,15 @@ func (QueryType) Enum() []any {
}
}
func (q QueryType) Validate() error {
switch q {
case QueryTypeBuilder, QueryTypeClickHouseSQL, QueryTypePromQL:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queryType: unsupported value %q; must be one of builder, clickhouse_sql, promql", q.StringValue())
}
}
type AlertCompositeQuery struct {
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true"`
@@ -111,18 +130,14 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true"`
CompareOperator CompareOperator `json:"op,omitzero"`
Target *float64 `json:"target,omitempty" format:"double"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor uint64 `json:"absentFor,omitempty"`
MatchType MatchType `json:"matchType,omitzero"`
TargetUnit string `json:"targetUnit,omitempty"`
Algorithm string `json:"algorithm,omitempty"`
Seasonality Seasonality `json:"seasonality,omitzero"`
SelectedQuery string `json:"selectedQueryName,omitempty"`
RequireMinPoints bool `json:"requireMinPoints,omitempty"`
RequiredNumPoints int `json:"requiredNumPoints,omitempty"`
Thresholds *RuleThresholdData `json:"thresholds,omitempty"`
Thresholds *RuleThresholdData `json:"thresholds" required:"true"`
}
func (rc *RuleCondition) SelectedQueryName() string {

View File

@@ -9,6 +9,7 @@ import (
"unicode/utf8"
"github.com/prometheus/alertmanager/config"
"github.com/swaggest/jsonschema-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
@@ -48,7 +49,6 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
)
@@ -60,12 +60,10 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName valuer.DisplayName `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero"`
Frequency valuer.TextDuration `json:"frequency,omitzero"`
AlertName valuer.DisplayName `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
RuleCondition *RuleCondition `json:"condition" required:"true"`
Labels map[string]string `json:"labels,omitempty"`
@@ -76,14 +74,12 @@ type PostableRule struct {
// Source captures the source url where rule has been created
Source string `json:"source,omitempty"`
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version string `json:"version"`
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
Evaluation *EvaluationEnvelope `json:"evaluation" required:"true"`
SchemaVersion string `json:"schemaVersion" required:"true" enum:"v2alpha1"`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
NotificationSettings *NotificationSettings `json:"notificationSettings" required:"true"`
}
type NotificationSettings struct {
@@ -229,27 +225,11 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
// processRuleDefaults applies the default values
// for the rule options that are blank or unset.
func (r *PostableRule) processRuleDefaults() {
if r.SchemaVersion == "" {
r.SchemaVersion = DefaultSchemaVersion
}
// TODO(srikanthccv): remove as this is now a legacy field
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
if r.EvalWindow.IsZero() {
r.EvalWindow = valuer.MustParseTextDuration("5m")
}
if r.Frequency.IsZero() {
r.Frequency = valuer.MustParseTextDuration("1m")
}
}
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
switch r.RuleCondition.CompositeQuery.QueryType {
case QueryTypeBuilder:
@@ -259,71 +239,6 @@ func (r *PostableRule) processRuleDefaults() {
case QueryTypePromQL:
r.RuleType = RuleTypeProm
}
if r.SchemaVersion == DefaultSchemaVersion {
thresholdName := CriticalThresholdName
if r.Labels != nil {
if severity, ok := r.Labels["severity"]; ok {
thresholdName = severity
}
}
// For anomaly detection with ValueIsBelow, negate the target
targetValue := r.RuleCondition.Target
if r.RuleType == RuleTypeAnomaly && r.RuleCondition.CompareOperator == ValueIsBelow && targetValue != nil {
negated := -1 * *targetValue
targetValue = &negated
}
thresholdData := RuleThresholdData{
Kind: BasicThresholdKind,
Spec: BasicRuleThresholds{{
Name: thresholdName,
TargetUnit: r.RuleCondition.TargetUnit,
TargetValue: targetValue,
MatchType: r.RuleCondition.MatchType,
CompareOperator: r.RuleCondition.CompareOperator,
Channels: r.PreferredChannels,
}},
}
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: &Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
},
}
if r.RuleCondition.AlertOnAbsent {
r.NotificationSettings.Renotify.AlertStates = append(r.NotificationSettings.Renotify.AlertStates, StateNoData)
}
}
}
}
func (r *PostableRule) MarshalJSON() ([]byte, error) {
type Alias PostableRule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
@@ -337,6 +252,19 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
var _ jsonschema.Preparer = PostableRule{}
// PrepareJSONSchema marks version and source deprecated: nothing reads them,
// they stay only so existing clients keep a stable schema.
func (PostableRule) PrepareJSONSchema(schema *jsonschema.Schema) error {
for _, name := range []string{"version", "source"} {
if property, ok := schema.Properties[name]; ok && property.TypeObject != nil {
property.TypeObject.WithDeprecated(true)
}
}
return nil
}
func isValidLabelName(ln string) bool {
if len(ln) == 0 {
return false
@@ -364,10 +292,6 @@ func (r *PostableRule) validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
for k, v := range r.Labels {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
@@ -411,18 +335,14 @@ func (r *PostableRule) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
if r.AlertType != "" {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
}
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
case "":
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alertType: field is required"))
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
}
if !r.RuleType.IsZero() {
@@ -440,6 +360,12 @@ func (r *PostableRule) Validate() error {
if r.RuleCondition.CompositeQuery == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
} else {
if err := r.RuleCondition.CompositeQuery.QueryType.Validate(); err != nil {
errs = append(errs, err)
}
if err := r.RuleCondition.CompositeQuery.PanelType.Validate(); err != nil {
errs = append(errs, err)
}
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
} else {
@@ -502,65 +428,18 @@ func (r *PostableRule) Validate() error {
func (r *PostableRule) validateSchemaVersion() []error {
switch r.SchemaVersion {
case DefaultSchemaVersion:
return r.validateV1()
case SchemaVersionV2Alpha1:
return r.validateV2Alpha1()
default:
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
"schemaVersion: unsupported value %q; must be one of %q, %q",
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
"schemaVersion: unsupported value %q; must be %q",
r.SchemaVersion, SchemaVersionV2Alpha1)}
}
}
func (r *PostableRule) validateV1() []error {
var errs []error
if r.RuleCondition.Target == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
}
if r.RuleCondition.CompareOperator.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
errs = append(errs, err)
}
if r.RuleCondition.MatchType.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
return errs
}
func (r *PostableRule) validateV2Alpha1() []error {
var errs []error
// TODO(srikanthccv): reject v1-only fields?
// if r.RuleCondition.Target != nil {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.CompareOperator.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.MatchType.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if len(r.PreferredChannels) > 0 {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// Require v2alpha1-specific fields
if r.RuleCondition.Thresholds == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
@@ -644,31 +523,6 @@ type GettableRule struct {
UpdatedBy *string `json:"updateBy" nullable:"true"`
}
func (g *GettableRule) MarshalJSON() ([]byte, error) {
type Alias GettableRule
switch g.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *g
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
// PlannedMaintenance and other entities. v1 handlers keep serializing
@@ -697,28 +551,3 @@ func NewRule(g *GettableRule) *Rule {
}
return r
}
func (r *Rule) MarshalJSON() ([]byte, error) {
type Alias Rule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}

View File

@@ -3,7 +3,6 @@ package ruletypes
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -29,8 +28,7 @@ func TestParseIntoRule(t *testing.T) {
"alertType": "METRIC_BASED_ALERT",
"description": "Test description",
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -45,11 +43,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -63,46 +61,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
{
name: "rule with default values applied",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultsRule",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 5.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.EvalWindow.Duration() != 5*time.Minute {
t.Errorf("Expected default eval window '5m', got '%v'", rule.EvalWindow)
}
if rule.Frequency.Duration() != time.Minute {
t.Errorf("Expected default frequency '1m', got '%v'", rule.Frequency)
}
},
},
{
@@ -110,6 +68,7 @@ func TestParseIntoRule(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "PromQLRule",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -123,11 +82,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -138,9 +97,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
}
@@ -177,233 +133,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
expectValidateError bool // Validate() error (write path only)
validate func(*testing.T, *PostableRule)
}{
{
name: "schema v1 - threshold name from severity label",
initRule: PostableRule{},
content: []byte(`{
"alert": "SeverityLabelTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 85.0,
"targetUnit": "%",
"matchType": "1",
"op": "1"
},
"labels": {
"severity": "warning",
"team": "platform"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated for v1")
}
threshold := rule.RuleCondition.Thresholds
if threshold.Kind != BasicThresholdKind {
t.Errorf("Expected BasicThresholdKind, got %s", threshold.Kind)
}
specs, ok := threshold.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", threshold.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "warning" {
t.Errorf("Expected threshold name 'warning' from severity label, got '%s'", spec.Name)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%', got '%s'", spec.TargetUnit)
}
if *spec.TargetValue != 85.0 {
t.Errorf("Expected TargetValue 85.0, got %v", *spec.TargetValue)
}
if spec.MatchType != rule.RuleCondition.MatchType {
t.Error("Expected MatchType to be copied from RuleCondition")
}
if spec.CompareOperator != rule.RuleCondition.CompareOperator {
t.Error("Expected CompareOp to be copied from RuleCondition")
}
// Verify evaluation envelope is populated
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated for v1")
}
if rule.Evaluation.Kind != RollingEvaluation {
t.Errorf("Expected RollingEvaluation, got %s", rule.Evaluation.Kind)
}
// Verify evaluation window matches rule settings
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v1 - uses critical threshold when no labels",
initRule: PostableRule{},
content: []byte(`{
"alert": "NoLabelsTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
spec := specs[0]
// Should default to CriticalThresholdName when no severity label
if spec.Name != CriticalThresholdName {
t.Errorf("Expected threshold name '%s', got '%s'", CriticalThresholdName, spec.Name)
}
},
},
{
name: "schema v1 - overwrites existing thresholds and evaluation",
initRule: PostableRule{},
content: []byte(`{
"alert": "OverwriteTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 80.0,
"targetUnit": "%",
"matchType": "1",
"op": "1",
"thresholds": {
"kind": "basic",
"spec": [{
"name": "existing_threshold",
"target": 50.0,
"targetUnit": "MB",
"ruleUnit": "bytes",
"matchType": "1",
"op": "1"
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "10m",
"frequency": "2m"
}
},
"frequency":"7m",
"evalWindow":"11m",
"labels": {
"severity": "critical"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "critical" {
t.Errorf("Expected threshold name 'critical' (overwritten), got '%s'", spec.Name)
}
if *spec.TargetValue != 80.0 {
t.Errorf("Expected TargetValue 80.0 (overwritten), got %v", *spec.TargetValue)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%' (overwritten), got '%s'", spec.TargetUnit)
}
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated")
}
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow to be overwritten to %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency to be overwritten to %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
initRule: PostableRule{},
@@ -645,19 +374,19 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "default schema version - defaults to v1 behavior",
name: "missing schema version - rejected on write",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultSchemaTest",
"alert": "MissingSchemaTest",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -672,24 +401,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 75.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 75.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.SchemaVersion != DefaultSchemaVersion {
t.Errorf("Expected default schemaVersion '%s', got '%s'", DefaultSchemaVersion, rule.SchemaVersion)
}
if rule.RuleCondition.Thresholds == nil {
t.Error("Expected Thresholds to be populated for default schema version")
}
if rule.Evaluation == nil {
t.Error("Expected Evaluation to be populated for default schema version")
}
},
kind: RuleDataKindJson,
expectValidateError: true,
},
}
@@ -737,11 +455,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"targetUnit": "ms",
"selectedQueryName": "A",
"thresholds": {
"kind": "basic",
"spec": [
@@ -907,428 +621,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
assert.Equal(t, 1, len(vector))
}
func TestAnomalyNegationEval(t *testing.T) {
tests := []struct {
name string
ruleJSON []byte
series *qbtypes.TimeSeries
shouldAlert bool
expectedValue float64
}{
{
name: "anomaly rule with ValueIsBelow - should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // below & at least once, should alert
{Timestamp: 2000, Value: -2.3},
},
},
shouldAlert: true,
expectedValue: -2.1,
},
{
name: "anomaly rule with ValueIsBelow; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`), // below & at least once, no value below -2.0
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -1.9},
{Timestamp: 2000, Value: -1.8},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsAbove; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`), // above & at least once, should alert
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 2.1}, // above 2.0, should alert
{Timestamp: 2000, Value: 2.2},
},
},
shouldAlert: true,
expectedValue: 2.1,
},
{
name: "anomaly rule with ValueIsAbove; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 1.1},
{Timestamp: 2000, Value: 1.2},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`), // below and all the times
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // all below -2
{Timestamp: 2000, Value: -2.2},
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: true,
expectedValue: -2.1, // max value when all are below threshold
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -3.0},
{Timestamp: 2000, Value: -1.0}, // above -2, breaks condition
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueOutsideBounds; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyOutOfBoundsTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 7.0,
"matchType": "1",
"op": "7",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -8.0}, // abs(−8) >= 7, alert
{Timestamp: 2000, Value: 5.0},
},
},
shouldAlert: true,
expectedValue: -8.0,
},
{
name: "non-anomaly threshold rule with ValueIsBelow; should alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 80.0}, // below 90, should alert
{Timestamp: 2000, Value: 85.0},
},
},
shouldAlert: true,
expectedValue: 80.0,
},
{
name: "non-anomaly rule with ValueIsBelow - should not alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 50.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 60.0}, // below, should alert
{Timestamp: 2000, Value: 90.0},
},
},
shouldAlert: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := PostableRule{}
err := json.Unmarshal(tt.ruleJSON, &rule)
if err != nil {
t.Fatalf("Failed to unmarshal rule: %v", err)
}
ruleThreshold, err := rule.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
t.Fatalf("unexpected error from GetRuleThreshold: %v", err)
}
resultVector, err := ruleThreshold.Eval(tt.series, "", EvalData{})
if err != nil {
t.Fatalf("unexpected error from Eval: %v", err)
}
shouldAlert := len(resultVector) > 0
if shouldAlert != tt.shouldAlert {
t.Errorf("Expected shouldAlert=%v, got %v. %s",
tt.shouldAlert, shouldAlert, tt.name)
}
if tt.shouldAlert && len(resultVector) > 0 {
sample := resultVector[0]
if sample.V != tt.expectedValue {
t.Errorf("Expected alert value=%.2f, got %.2f. %s",
tt.expectedValue, sample.V, tt.name)
}
}
})
}
}
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",

View File

@@ -147,7 +147,7 @@ func TestV2MinimalReadShape(t *testing.T) {
body := readBody(t, stored)
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
for _, field := range []string{"labels", "annotations", "description"} {
assert.NotContains(t, body, field)
}

File diff suppressed because it is too large Load Diff

369
tests/e2e/drivers/channels.ts generated Normal file
View File

@@ -0,0 +1,369 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { expect, type Locator, type Page } from '@playwright/test';
import { authToken } from '../helpers/common';
import { driver, kit, seeded } from './kit';
// Search and the type filter are both served by the API, so every assertion on
// the list is also a guard on the query the list sends.
export const PATH = {
list: '/alerts?tab=Channels',
new: '/alerts/channels/new',
/** Matches any channel's edit route. */
edit: /\/alerts\/channels\/edit\//,
} as const;
const CHANNELS_API = '/api/v2/notification_channels';
/**
* One entry per channel type the form offers. `fields` carries only what the
* validator refuses to save without, and `spec` is the same channel as the API
* takes it, for specs that seed rather than type.
*/
export const CHANNEL_TYPES: ChannelType[] = [
{
label: 'Slack',
kind: 'slack',
fields: [['webhook-url-textbox', 'https://hooks.slack.com/services/T/B/X']],
edit: ['webhook-url-textbox', 'https://hooks.slack.com/services/T/B/edited'],
spec: { apiUrl: 'https://hooks.slack.com/services/T/B/X' },
},
{
label: 'Webhook',
kind: 'webhook',
fields: [['webhook-url-textbox', 'https://example.test/e2e-hook']],
edit: ['webhook-url-textbox', 'https://example.test/e2e-hook-edited'],
spec: { url: 'https://example.test/e2e-hook' },
},
{
label: 'Pagerduty',
kind: 'pagerduty',
fields: [['pager-routing-key-textbox', 'e2e-routing-key']],
edit: ['pager-routing-key-textbox', 'e2e-routing-key-edited'],
spec: { routingKey: 'e2e-routing-key' },
},
{
label: 'Opsgenie',
kind: 'opsgenie',
fields: [['opsgenie-api-key-textbox', 'e2e-api-key']],
edit: ['opsgenie-api-key-textbox', 'e2e-api-key-edited'],
spec: { apiKey: 'e2e-api-key' },
},
{
label: 'Email',
kind: 'email',
fields: [['email-to-textbox', 'e2e@signoz.test']],
edit: ['email-to-textbox', 'edited@signoz.test'],
spec: { to: 'e2e@signoz.test' },
},
{
label: 'Microsoft Teams',
kind: 'msteams',
fields: [
['webhook-url-textbox', 'https://outlook.office.com/webhook/e2e-teams'],
],
edit: [
'webhook-url-textbox',
'https://outlook.office.com/webhook/e2e-teams-edited',
],
spec: { webhookUrl: 'https://outlook.office.com/webhook/e2e-teams' },
},
{
label: 'Google Chat',
kind: 'googlechat',
// the validator accepts https on chat.googleapis.com and nothing else
fields: [
[
'webhook-url-textbox',
'https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t',
],
],
edit: [
'webhook-url-textbox',
'https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=edited',
],
spec: {
webhookUrl:
'https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t',
},
},
{
label: 'Jira',
kind: 'jira',
// the site has to be an https atlassian.net host
fields: [
['jira-site-textbox', 'https://e2e.atlassian.net'],
['jira-email-textbox', 'e2e@signoz.test'],
['jira-api-token-textbox', 'e2e-api-token'],
['jira-project-textbox', 'E2E'],
['jira-issue-type-textbox', 'Task'],
],
edit: ['jira-project-textbox', 'E2EEDIT'],
spec: {
site: 'https://e2e.atlassian.net',
email: 'e2e@signoz.test',
apiToken: 'e2e-api-token',
project: 'E2E',
issueType: 'Task',
},
},
{
label: 'Jira Service Management Ops',
kind: 'jsmops',
fields: [['jsmops-api-key-textbox', 'e2e-api-key']],
edit: ['jsmops-api-key-textbox', 'e2e-api-key-edited'],
spec: { apiKey: 'e2e-api-key' },
},
{
label: 'incident.io',
kind: 'incidentio',
// the path has to carry an id after /v2/alert_events/http/
fields: [
[
'incidentio-url-textbox',
'https://api.incident.io/v2/alert_events/http/e2e-source',
],
['incidentio-token-textbox', 'e2e-token'],
],
edit: ['incidentio-token-textbox', 'e2e-token-edited'],
spec: {
url: 'https://api.incident.io/v2/alert_events/http/e2e-source',
token: 'e2e-token',
},
},
{
label: 'Telegram',
kind: 'telegram',
// the API rejects a chat id of 0, so this one is deliberately non-zero
fields: [
['telegram-bot-token-textbox', '123456:e2e-bot-token'],
['telegram-chat-id-textbox', '-1001234567890'],
],
edit: ['telegram-bot-token-textbox', '123456:e2e-bot-token-edited'],
spec: { botToken: '123456:e2e-bot-token', chatId: -1001234567890 },
},
];
export interface SeededChannel {
id: string;
name: string;
}
/** Everything a channel spec needs: the form labels, and the API equivalent. */
export interface ChannelType {
label: string;
kind: string;
/** Only what the form refuses to save without, in the order it is filled. */
fields: [string, string][];
/**
* The field an edit pass changes, and the value to change it to. Spelled out
* per type rather than derived: appending to a Jira site or a Google Chat
* webhook breaks the format its validator demands, and the save then never
* leaves the page.
*/
edit: [string, string];
spec: Record<string, unknown>;
}
export interface Channels {
list: ChannelList;
form: ChannelForm;
api: ChannelApi;
}
interface ChannelApi {
create(
name: string,
kind?: string,
spec?: Record<string, unknown>,
): Promise<SeededChannel>;
remove(id: string | undefined): Promise<void>;
}
interface ChannelList {
goto(options?: { search?: string; kind?: string }): Promise<void>;
search(): Locator;
rows(): Locator;
row(name: string): Locator;
openRowMenu(name: string): Promise<void>;
chooseMenuItem(name: string): Promise<void>;
jsonDialog(): Locator;
noResults(): Locator;
deletePrompt(name: string): Locator;
confirmDelete(): Promise<void>;
clearSearch(): Promise<void>;
}
interface ChannelForm {
gotoNew(): Promise<void>;
name(): Locator;
pickType(label: string): Promise<void>;
fillField(testId: string, value: string): Promise<void>;
/** Saves and returns the id the API assigned, so the test can clean up. */
saveAndReturnId(): Promise<string>;
/** Saves an existing channel and waits for the write to land. */
saveEdit(): Promise<void>;
save(): Promise<void>;
}
/**
* Seeds a channel through the v2 API. The spec only has to be valid enough for
* the API to store it: nothing is ever delivered.
*/
async function createViaApi(
page: Page,
name: string,
kind = 'slack',
spec: Record<string, unknown> = {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#e2e',
},
): Promise<SeededChannel> {
const token = await authToken(page);
const res = await page.request.post(CHANNELS_API, {
data: { generateName: true, displayName: name, config: { kind, spec } },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(`POST ${CHANNELS_API} ${res.status()}: ${await res.text()}`);
}
const json = (await res.json()) as { data: { id: string } };
return { id: String(json.data.id), name };
}
/**
* Tolerates an empty id and an already-deleted channel, so a cleanup block
* never has to branch on whether the seed got that far.
*/
async function removeViaApi(page: Page, id: string | undefined): Promise<void> {
if (!id) {
return;
}
// A test that already failed often leaves a closed page behind, and a throw
// here would replace its error with one about the cleanup.
try {
const token = await authToken(page);
await page.request.delete(`${CHANNELS_API}/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
/* the channel outlives the run rather than hiding why it failed */
}
}
function channelsDriver(page: Page): Channels {
const rows = (): Locator => page.locator('tbody tr');
const list: ChannelList = {
async goto({ search, kind } = {}) {
const url = new URL(PATH.list, 'http://localhost');
if (search) {
url.searchParams.set('search', search);
}
if (kind) {
url.searchParams.set('kind', kind);
}
await page.goto(`${url.pathname}${url.search}`);
await expect(list.search()).toBeVisible();
},
search: () => page.getByTestId('channels-search'),
rows,
row: (name) => rows().filter({ hasText: name }),
async openRowMenu(name) {
await page.getByRole('button', { name: `Actions for ${name}` }).click();
await expect(page.getByRole('menu')).toBeVisible();
},
async chooseMenuItem(name) {
await page.getByRole('menuitem', { name }).click();
},
jsonDialog: () => page.getByTestId('channel-json-dialog'),
noResults: () => page.getByText('No matching channels'),
deletePrompt: (name) =>
page.getByText(`Are you sure you want to delete the ${name} channel?`),
async confirmDelete() {
await Promise.all([
page.waitForResponse(
(r) => r.url().includes(CHANNELS_API) && r.request().method() === 'DELETE',
),
page.getByRole('button', { name: 'Delete', exact: true }).click(),
]);
},
async clearSearch() {
await page.getByRole('button', { name: 'Clear Search' }).click();
await expect(list.search()).toHaveValue('');
},
};
const form: ChannelForm = {
async gotoNew() {
await page.goto(PATH.new);
await expect(form.name()).toBeVisible();
},
name: () => page.getByTestId('channel-name-textbox'),
async pickType(label) {
await page.getByTestId('channel-type-select').click();
await page.getByRole('option', { name: label, exact: true }).click();
},
async fillField(testId, value) {
await page.getByTestId(testId).fill(value);
},
async saveAndReturnId() {
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes(CHANNELS_API) && r.request().method() === 'POST',
),
page.getByTestId('save-channel-button').click(),
]);
return ((await response.json()) as { data: { id: string } }).data.id;
},
async saveEdit() {
await Promise.all([
page.waitForResponse(
(r) => r.url().includes(CHANNELS_API) && r.request().method() === 'PUT',
),
page.getByTestId('save-channel-button').click(),
]);
},
async save() {
await page.getByTestId('save-channel-button').click();
},
};
const api: ChannelApi = {
create: (name, kind, spec) => createViaApi(page, name, kind, spec),
remove: (id) => removeViaApi(page, id),
};
return { list, form, api };
}
const withChannels = driver('channels', ({ page }) => channelsDriver(page));
// One slack and one email channel sharing a prefix, so a search can hold both
// and the type filter has something to tell apart.
const withChannelSet = seeded(
'channelSet',
async ({ page, workerIndex }) => {
const prefix = `e2e-nc-${workerIndex}-${Date.now()}`;
const slack = await createViaApi(page, `${prefix}-slack`);
const email = await createViaApi(page, `${prefix}-email`, 'email', {
to: 'e2e@signoz.test',
});
return { prefix, slack, email };
},
async ({ page, value }) => {
await removeViaApi(page, value.slack.id);
await removeViaApi(page, value.email.id);
},
);
export const test = withChannelSet(withChannels(kit));
export { expect, onAdminPage } from './kit';

View File

@@ -0,0 +1,36 @@
import { CHANNEL_TYPES, expect, test } from '../../../drivers/channels';
// One pass per channel type, filling only what the form refuses to save
// without. The point is that every type can be created at all: a type whose
// settings component or validator drifts fails here rather than in support.
test.describe('Notification channels — create one of each type', () => {
CHANNEL_TYPES.forEach(({ label, fields }, index) => {
const number = String(index + 1).padStart(2, '0');
test(`TC-${number} a ${label} channel saves and shows up in the list`, async ({
channels,
}) => {
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const name = `e2e-nct-${slug}-${Date.now()}`;
let id: string | undefined;
try {
await channels.form.gotoNew();
await channels.form.name().fill(name);
await channels.form.pickType(label);
for (const [testId, value] of fields) {
// eslint-disable-next-line no-await-in-loop
await channels.form.fillField(testId, value);
}
id = await channels.form.saveAndReturnId();
await channels.list.goto({ search: name });
await expect(channels.list.row(name)).toBeVisible();
} finally {
await channels.api.remove(id);
}
});
});
});

View File

@@ -0,0 +1,43 @@
import { expect, test } from '../../../drivers/channels';
test.describe('Notification channels — create', () => {
test('TC-01 a webhook channel saves and shows up in the list', async ({
channels,
}) => {
const name = `e2e-ncc-${Date.now()}`;
let id: string | undefined;
try {
await channels.form.gotoNew();
await channels.form.name().fill(name);
await channels.form.pickType('Webhook');
await channels.form.fillField(
'webhook-url-textbox',
'https://example.test/e2e-hook',
);
id = await channels.form.saveAndReturnId();
await channels.list.goto({ search: name });
await expect(channels.list.row(name)).toBeVisible();
} finally {
await channels.api.remove(id);
}
});
test('TC-02 saving without a name reports the missing field', async ({
channels,
authedPage,
}) => {
await channels.form.gotoNew();
// Save stays off until the form is touched, so the webhook goes in first and
// the name is what is left missing.
await channels.form.fillField(
'webhook-url-textbox',
'https://hooks.slack.com/services/T/B/X',
);
await channels.form.save();
await expect(authedPage.getByText('Channel name is mandatory')).toBeVisible();
});
});

View File

@@ -0,0 +1,24 @@
import { expect, test } from '../../../drivers/channels';
test.describe('Notification channels — delete', () => {
test('TC-01 deleting from the row menu takes the channel out of the list', async ({
channels,
}) => {
const name = `e2e-ncd-${Date.now()}`;
const { id } = await channels.api.create(name);
try {
await channels.list.goto({ search: name });
await channels.list.openRowMenu(name);
await channels.list.chooseMenuItem('Delete');
await expect(channels.list.deletePrompt(name)).toBeVisible();
await channels.list.confirmDelete();
await expect(channels.list.row(name)).toHaveCount(0);
} finally {
// the test deletes it; this only catches a run that stopped earlier
await channels.api.remove(id);
}
});
});

View File

@@ -0,0 +1,39 @@
import { CHANNEL_TYPES, expect, test } from '../../../drivers/channels';
// The mirror of the create pass: seed a channel of each type through the API,
// open it, change the field its table entry nominates and check the change
// survives a reload. A prefill or a save that drops a type's fields fails here.
test.describe('Notification channels — edit one of each type', () => {
CHANNEL_TYPES.forEach(({ label, kind, fields, edit, spec }, index) => {
const number = String(index + 1).padStart(2, '0');
const [testId, edited] = edit;
// every entry nominates one of its own required fields, so a miss here
// fails the assertion below rather than skipping it
const original = fields.find(([id]) => id === testId)?.[1] ?? '';
test(`TC-${number} an edited ${label} channel keeps its change`, async ({
channels,
authedPage,
}) => {
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const name = `e2e-nte-${slug}-${Date.now()}`;
let id: string | undefined;
try {
({ id } = await channels.api.create(name, kind, spec));
await authedPage.goto(`/alerts/channels/edit/${id}`);
const field = authedPage.getByTestId(testId);
await expect(field).toHaveValue(original);
await field.fill(edited);
await channels.form.saveEdit();
await authedPage.goto(`/alerts/channels/edit/${id}`);
await expect(authedPage.getByTestId(testId)).toHaveValue(edited);
} finally {
await channels.api.remove(id);
}
});
});
});

View File

@@ -0,0 +1,63 @@
import { expect, test } from '../../../drivers/channels';
test.describe('Notification channels — list', () => {
test('TC-01 search narrows the list to the matching channel', async ({
channels,
channelSet,
}) => {
await channels.list.goto({ search: channelSet.prefix });
await expect(channels.list.rows()).toHaveCount(2);
await channels.list.search().fill(channelSet.slack.name);
await expect(channels.list.row(channelSet.slack.name)).toBeVisible();
await expect(channels.list.row(channelSet.email.name)).toHaveCount(0);
});
test('TC-02 the kind filter keeps only channels of that kind', async ({
channels,
channelSet,
}) => {
await channels.list.goto({ search: channelSet.prefix, kind: 'email' });
await expect(channels.list.row(channelSet.email.name)).toBeVisible();
await expect(channels.list.row(channelSet.slack.name)).toHaveCount(0);
});
test('TC-03 a search matching nothing offers to clear itself', async ({
channels,
channelSet,
}) => {
await channels.list.goto({ search: `${channelSet.prefix}-nothing-matches` });
await expect(channels.list.noResults()).toBeVisible();
await channels.list.clearSearch();
});
test('TC-04 clicking a row opens that channel for editing', async ({
channels,
channelSet,
authedPage,
}) => {
await channels.list.goto({ search: channelSet.slack.name });
await channels.list.row(channelSet.slack.name).click();
await authedPage.waitForURL(/\/alerts\/channels\/edit\//);
await expect(channels.form.name()).toHaveValue(channelSet.slack.name);
});
test('TC-05 View JSON shows the channel with its secrets masked', async ({
channels,
channelSet,
authedPage,
}) => {
await channels.list.goto({ search: channelSet.slack.name });
await channels.list.openRowMenu(channelSet.slack.name);
await channels.list.chooseMenuItem('View JSON');
await expect(channels.list.jsonDialog()).toBeVisible();
await expect(channels.list.jsonDialog()).toContainText('"kind": "slack"');
await expect(
authedPage.getByTestId('channel-json-toggle-secrets'),
).toBeVisible();
});
});

View File

@@ -32,12 +32,12 @@ def create_alert_rule(signoz: types.SigNoz, get_token: Callable[[str, str], str]
def _create_alert_rule(rule_data: dict) -> str:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/rules"),
signoz.self.host_configs["8080"].get("/api/v2/rules"),
json=rule_data,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
assert response.status_code == HTTPStatus.CREATED, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
rule_id = response.json()["data"]["id"]
rule_ids.append(rule_id)
return rule_id
@@ -45,11 +45,11 @@ def create_alert_rule(signoz: types.SigNoz, get_token: Callable[[str, str], str]
def _delete_alert_rule(rule_id: str):
logger.info("Deleting rule: %s", {"rule_id": rule_id})
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if response.status_code != HTTPStatus.OK:
if response.status_code != HTTPStatus.NO_CONTENT:
raise Exception( # pylint: disable=broad-exception-raised
f"Failed to delete rule, api returned {response.status_code} with response: {response.text}"
)
@@ -89,19 +89,22 @@ def create_alert_rule_with_channel(
def delete_all_rules(signoz: types.SigNoz, token: str) -> None:
# the v3 list is paginated; 200 is its maximum page and more rules than
# that would mean a seeding bug, not a second page
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
signoz.self.host_configs["8080"].get("/api/v3/rules"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
for rule in response.json()["data"]:
for rule in response.json()["data"]["rules"]:
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert delete_response.status_code == HTTPStatus.OK, f"failed to delete rule {rule['id']}: {delete_response.text}"
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"failed to delete rule {rule['id']}: {delete_response.text}"
@pytest.fixture(name="seed_alert_rules", scope="function")

View File

@@ -573,24 +573,3 @@ def test_error_contract(
error = response.json()["error"]
assert error["code"] == expected_code, f"params {params!r}"
assert expected_message_part in error["message"], f"params {params!r}: {error['message']}"
def test_v2_list_still_serves_bare_array(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[str, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL_NAME, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert isinstance(data, list), "deprecated v2 must keep returning a bare array"
assert {rule["alert"] for rule in data} == {r["alert"] for r in SEED_RULES}

View File

@@ -0,0 +1,156 @@
import time
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
VALID_RULE = {
"alert": "validation probe",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "validation_probe_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": False, "renotify": {"enabled": False}},
"schemaVersion": "v2alpha1",
}
def test_create_rejects_missing_alert_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {key: value for key, value in VALID_RULE.items() if key != "alertType"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "alertType: field is required", "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_unknown_panel_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "compositeQuery": {**VALID_RULE["condition"]["compositeQuery"], "panelType": "pie"}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'condition.compositeQuery.panelType: unsupported value "pie"; must be one of value, table, graph', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_malformed_json(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
data='{"alert": ',
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "failed to unmarshal rule",
"errors": [],
"suggestions": [],
},
}
def test_timeline_rejects_unknown_state(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule(VALID_RULE)
end_ms = int(time.time() * 1000)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}/history/timeline"),
params={"start": end_ms - 3_600_000, "end": end_ms, "state": "bogus"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "invalid query parameters",
"errors": [],
"suggestions": [],
},
}