Compare commits

..

50 Commits

Author SHA1 Message Date
Abhi Kumar
5a1a81e441 fix(dashboard): drop PromQL from scatter plot query types 2026-10-13 18:13:23 +05:30
Abhi Kumar
4163c42cd4 fix(dashboard): declare scatter plot query modes 2026-10-13 18:10:01 +05:30
Abhi Kumar
1cc203abb4 refactor(dashboard): trim scatter plot panel comments and share the joined table helper 2026-10-13 18:08:16 +05:30
Abhi Kumar
57054f9be2 fix(dashboard): sync group by only into a newly added query 2026-10-13 18:08:03 +05:30
Abhi Kumar
42385ae756 fix(dashboard): warn of the scatter plot cap only when groups were capped 2026-10-13 18:08:03 +05:30
Abhi Kumar
9bc4af8957 feat(dashboard): let scatter plot thresholds mark the x or y axis 2026-10-13 18:08:03 +05:30
Abhi Kumar
34d16d6a08 feat(dashboard): use colorBy and sizeBy in the scatter plot, labelled Color by and Size by 2026-10-13 18:07:42 +05:30
Abhi Kumar
722e90496b feat(dashboard): explain mismatched group bys in the scatter plot 2026-10-13 18:07:42 +05:30
Abhi Kumar
20e26297fb refactor(dashboard): move scatter plot panel helpers into utils 2026-10-13 18:07:42 +05:30
Abhi Kumar
998f50eb72 style(charts): match scatter tooltip spacing and marker to the heatmap tooltip and legend 2026-10-13 18:07:42 +05:30
Abhi Kumar
d0d6ee1b99 fix(charts): drop the scatter tooltip size row when it repeats x or y 2026-10-13 18:07:42 +05:30
Abhi Kumar
550e695f78 feat(dashboard): sync scatter plot group by across queries
Panel kinds declare which builder fields to keep equal across queries. A query
added or switched to another signal takes the others' values, since a signal
switch reloads its own.
2026-10-13 18:07:41 +05:30
Abhi Kumar
2afec6cc18 feat(dashboard): warn when a scatter plot dimension isn't in the results 2026-10-13 18:07:35 +05:30
Abhi Kumar
aabd375c01 feat(query-builder): show the scatter plot's default limit as the limit placeholder 2026-10-13 18:07:35 +05:30
Abhi Kumar
8ad2a0a758 feat(dashboard): move scatter plot notes from the footer into the panel warning 2026-10-13 18:07:35 +05:30
Abhi Kumar
9352e3ed98 fix(dashboard): limit scatter plot queries to 10,000 groups on the server
Only a lone builder query gets the default: per-query limits on several
queries can keep different groups and break the join.
2026-10-13 18:07:35 +05:30
Abhi Kumar
7bff9f8b42 fix(dashboard): cap the scatter plot at 10,000 dots
A canvas path of a few hundred thousand dots fails to paint and takes the later series with it.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
bdf2661556 fix(dashboard): redraw the scatter plot after an empty state
The chart area stays mounted, since the size observer binds to its node only once.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
11bd4811db refactor(dashboard): build scatter plot axes from the shared axis fields
Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
623ce30c72 feat(dashboard): lay out scatter plot axes with label, range and scale rows
Range help says when a log axis ignores a bound at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
f1d552c88a feat(dashboard): restyle scatter plot dimensions with field info and auto labels
Colour is a multi-select of group-by keys; Auto options name the column they resolve to.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
af90334bfc feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
f8826a3b32 fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:35 +05:30
Abhi Kumar
8e4a345369 feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:23 +05:30
Abhi Kumar
e79ab69c8e feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:23 +05:30
Abhi Kumar
1210da3efe fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-13 18:07:23 +05:30
Abhi Kumar
5046152aeb fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-13 18:07:23 +05:30
Abhi Kumar
ea8b4d5cbb feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:23 +05:30
Abhi Kumar
73f3bfb408 feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-13 18:07:22 +05:30
Abhi Kumar
0887d9d12d refactor(charts): trim scatter chart layer comments and dead code 2026-10-13 18:06:50 +05:30
Abhi Kumar
0ab0bf1750 fix(charts): pick the scatter auto scale by how the points spread
Max/min decades let one outlier decide and never left linear with zeros.
Compare middle-half coverage instead, falling back to symlog.
2026-10-13 18:06:50 +05:30
Abhi Kumar
62fd0f7c69 fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-13 18:06:50 +05:30
Abhi Kumar
53995a305f fix(dashboard): honour scatter plot soft limits on every axis scale
Only Auto weighs the soft limits when choosing a scale; an explicit scale keeps its choice.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:50 +05:30
Abhi Kumar
51f801872a feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:50 +05:30
Abhi Kumar
fef6041aa0 fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:50 +05:30
Abhi Kumar
f7fc5c7982 fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:50 +05:30
Abhi Kumar
24cf2a335e feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:50 +05:30
Abhi Kumar
9883c751d0 fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:49 +05:30
Abhi Kumar
f62b75aca4 fix(charts): outline the hovered scatter point
Snap the ring to whole pixels so uPlot's ceil on left/top doesn't shift it
off the disc.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:49 +05:30
Abhi Kumar
d478c2b32c feat(charts): scatter axis scales and titles
auto picks log only for positive values spanning three decades; log falls
back to symlog when given values at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-13 18:06:49 +05:30
Abhi Kumar
e8b9915154 chore: scatter plot base work 2026-10-13 18:06:49 +05:30
Abhi Kumar
ddb75b80df feat(dashboards): add an axis to scatter plot thresholds 2026-10-13 18:05:32 +05:30
Naman Verma
1c6464189c chore: add more validations for scatter plot 2026-10-13 18:05:32 +05:30
Abhi Kumar
ee731733d0 feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-13 18:05:15 +05:30
Abhi Kumar
34afabd7f7 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-13 18:05:15 +05:30
Abhi Kumar
c459702daf feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-13 18:05:15 +05:30
Abhi Kumar
5acab42d52 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-13 18:05:15 +05:30
Abhi Kumar
b0afb26fad feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-13 18:05:15 +05:30
Abhi Kumar
81e89f3f95 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-13 18:05:15 +05:30
Abhi Kumar
7105e0f40a feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-13 18:05:14 +05:30
346 changed files with 14781 additions and 7400 deletions

View File

@@ -17,4 +17,3 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.

View File

@@ -93,18 +93,17 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceNotificationChannel).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -251,7 +251,6 @@ components:
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
propertyName: kind
oneOf:
@@ -265,7 +264,6 @@ components:
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
properties:
@@ -375,18 +373,6 @@ components:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig:
properties:
kind:
enum:
- telegram
type: string
spec:
$ref: '#/components/schemas/AlertmanagertypesChannelTelegramConfig'
required:
- kind
- spec
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
properties:
kind:
@@ -539,7 +525,6 @@ components:
- jira
- jsmops
- incidentio
- telegram
type: string
AlertmanagertypesChannelListOrder:
enum:
@@ -744,31 +729,6 @@ components:
- title
- value
type: object
AlertmanagertypesChannelTelegramConfig:
properties:
apiUrl:
type: string
botToken:
format: password
type: string
chatId:
format: int64
type: integer
disableNotifications:
type: boolean
message:
type: string
messageThreadId:
type: integer
sendResolved:
nullable: true
type: boolean
title:
type: string
required:
- botToken
- chatId
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
@@ -1062,8 +1022,6 @@ components:
- jsmops_configs
- required:
- incidentio_configs
- required:
- telegram_configs
- required:
- discord_configs
- required:
@@ -1084,6 +1042,8 @@ components:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
@@ -1159,7 +1119,7 @@ components:
type: array
telegram_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesTelegramReceiverConfig'
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
@@ -1303,7 +1263,7 @@ components:
type: array
telegram_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesTelegramReceiverConfig'
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
@@ -1374,34 +1334,6 @@ components:
- timezone
- startTime
type: object
AlertmanagertypesTelegramReceiverConfig:
properties:
api_url:
$ref: '#/components/schemas/ConfigURLType2'
chat:
format: int64
type: integer
chat_file:
type: string
disable_notifications:
type: boolean
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
message:
type: string
message_thread_id:
type: integer
parse_mode:
type: string
send_resolved:
type: boolean
title:
type: string
token:
type: string
token_file:
type: string
type: object
AlertmanagertypesTestableNotificationChannel:
properties:
config:
@@ -3770,6 +3702,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4512,6 +4451,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4527,6 +4467,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4540,6 +4481,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4625,6 +4567,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4704,6 +4658,16 @@ 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:
@@ -4892,6 +4856,104 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5005,6 +5067,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text

View File

@@ -277,7 +277,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.ExpandAnnotations(expand)
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -136,25 +136,5 @@
"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",
"channel_read_only_title": "You can view this channel but not change it",
"channel_read_only_help": "Changing a channel needs update permission on it. Ask an admin to grant it, or open the channel read-only.",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"help_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA… .",
"field_telegram_chat_id": "Chat ID",
"help_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Topic ID (optional)",
"help_telegram_message_thread_id": "Posts into one topic of a forum group. Leave empty to post in the group itself.",
"field_telegram_message": "Message",
"help_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL (optional)",
"help_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"help_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices.",
"telegram_required_fields": "Bot token and chat ID are required",
"field_telegram_title": "Title",
"help_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped."
}
"channel_name_required": "Channel name is mandatory"
}

View File

@@ -15,104 +15,14 @@
"button_test_channel": "Test",
"button_return": "Back",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"channel_creation_done": "Successfully created the channel",
"channel_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",
"channel_edit_failed": "An unexpected error occurred while updating this channel",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"test_unsupported": "Sorry, this channel type does not support test yet",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"page_title_view": "View Notification Channel",
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
"tooltip_slack_url": "The URL of the slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) to send alerts to. Learn more about slack integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/slack/).",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"placeholder_slack_description": "Description",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"field_slack_title_link": "Title link",
"field_slack_color": "Color",
"help_slack_color": "good, warning, danger, or a hex value like #439FE0. Templates are allowed.",
"placeholder_slack_color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
"field_slack_pretext": "Pretext",
"help_slack_pretext": "Shown above the attachment block",
"field_slack_fallback": "Fallback text",
"help_slack_fallback": "Plain text shown where the attachment cannot render, such as push notifications",
"field_slack_footer": "Footer",
"field_slack_fields": "Fields",
"help_slack_fields": "Extra entries rendered as a table inside the attachment",
"placeholder_slack_field_title": "Title",
"placeholder_slack_field_value": "Value",
"field_slack_field_short": "Short",
"add_slack_field": "Add field",
"remove_slack_field": "Remove field",
"field_slack_actions": "Actions",
"help_slack_actions": "Buttons rendered under the attachment. A button with a URL links out.",
"placeholder_slack_action_text": "Button text",
"placeholder_slack_action_url": "https://runbook.example.com",
"placeholder_slack_action_type": "button",
"placeholder_slack_action_name": "Name (Slack app callbacks)",
"placeholder_slack_action_value": "Value (Slack app callbacks)",
"placeholder_slack_action_style": "Style: default, primary or danger",
"placeholder_slack_action_confirm": "Confirmation prompt (optional)",
"add_slack_action": "Add action",
"remove_slack_action": "Remove action",
"tooltip_email_to": "Enter email addresses separated by commas.",
"help_email_to": "Email address(es) to send alerts to (comma separated)",
"field_email_to": "To",
"placeholder_email_to": "To",
"help_email_html": "Send email in html format",
"field_email_html": "Email body template",
"placeholder_email_html": "Email body template",
"field_webhook_url": "Webhook URL",
"tooltip_webhook_url": "The URL of the webhook to send alerts to. Learn more about webhook integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/webhook/). Integrates with [Incident.io](https://signoz.io/docs/alerts-management/notification-channel/incident-io/), [Rootly](https://signoz.io/docs/alerts-management/notification-channel/rootly/), [Zenduty](https://signoz.io/docs/alerts-management/notification-channel/zenduty/) and [more](https://signoz.io/docs/alerts-management/notification-channel/webhook/#my-incident-management-tool-is-not-listed-can-i-still-integrate).",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"webhook_url_required": "Webhook URL is mandatory",
"field_webhook_bearer_token": "Bearer token (optional)",
"help_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
"tooltip_slack_url": "The URL of the slack [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) to send alerts to. Learn more about slack integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/slack/).",
"tooltip_pager_routing_key": "Learn how to obtain the routing key from your PagerDuty account [here](https://signoz.io/docs/alerts-management/notification-channel/pagerduty/#obtaining-integration-or-routing-key).",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
"field_pager_details": "Additional Information",
"field_pager_component": "Component",
"field_pager_group": "Group",
"field_pager_class": "Class",
"field_pager_client": "Client",
"field_pager_client_url": "Client URL",
"placeholder_pager_description": "Description",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_pager_description": "Shows up as description in pagerduty",
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
"field_opsgenie_message": "Message",
"field_opsgenie_priority": "Priority",
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
"help_opsgenie_description": "Shows up as description in opsgenie",
"tooltip_email_to": "Enter email addresses separated by commas.",
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
@@ -168,41 +78,78 @@
"field_jsmops_tags": "Tags",
"placeholder_jsmops_tags": "Type a tag and press Enter",
"help_jsmops_tags": "Tags added to every alert.",
"incidentio_tip": "Create an HTTP alert source in incident.io (On-call → Alert routing → Sources) and paste its URL and token below.",
"incidentio_tip": "Create an HTTP alert source in incident.io (On-call \u2192 Alert routing \u2192 Sources) and paste its URL and token below.",
"incidentio_tip_link": "Learn how",
"field_incidentio_url": "Alert source URL",
"help_incidentio_url": "The alert events URL from the source's setup page, e.g. https://api.incident.io/v2/alert_events/http/<source_config_id>.",
"field_incidentio_token": "Token",
"help_incidentio_token": "The alert source's secret token, from the same setup page.",
"field_incidentio_title": "Title",
"help_incidentio_title": "Template for the alert title. Kept stable while the alert fires — incident.io ignores content updates on repeat events.",
"help_incidentio_title": "Template for the alert title. Kept stable while the alert fires \u2014 incident.io ignores content updates on repeat events.",
"field_incidentio_description": "Description",
"help_incidentio_description": "Template for the alert description. Markdown, rendered natively by incident.io.",
"incidentio_required_fields": "Alert source URL and token are required",
"telegram_required_fields": "Bot token and chat ID are required",
"incidentio_url_invalid": "URL must be an incident.io alert events URL (https://api.incident.io/v2/alert_events/http/<source_config_id>)",
"field_incidentio_metadata": "Additional metadata",
"help_incidentio_metadata": "Key-value pairs added to every alert's metadata, on top of the alert's labels (these win on a key clash). Values may use templates, e.g. {{ .CommonLabels.severity }}.",
"placeholder_incidentio_metadata_key": "Key",
"placeholder_incidentio_metadata_value": "Value",
"button_incidentio_add_metadata": "Add metadata",
"channel_read_only_title": "You can view this channel but not change it",
"channel_read_only_help": "Changing a channel needs update permission on it. Ask an admin to grant it, or open the channel read-only.",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"help_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA… .",
"field_telegram_chat_id": "Chat ID",
"help_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Topic ID (optional)",
"help_telegram_message_thread_id": "Posts into one topic of a forum group. Leave empty to post in the group itself.",
"field_telegram_message": "Message",
"help_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL (optional)",
"help_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"help_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices.",
"field_telegram_title": "Title",
"help_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped."
}
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
"help_email_to": "Email address(es) to send alerts to (comma separated)",
"field_email_to": "To",
"placeholder_email_to": "To",
"help_email_html": "Send email in html format",
"field_email_html": "Email body template",
"placeholder_email_html": "Email body template",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
"field_pager_details": "Additional Information",
"field_pager_component": "Component",
"field_pager_group": "Group",
"field_pager_class": "Class",
"field_pager_client": "Client",
"field_pager_client_url": "Client URL",
"field_opsgenie_message": "Message",
"field_opsgenie_priority": "Priority",
"placeholder_slack_description": "Description",
"placeholder_pager_description": "Description",
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",
"help_pager_component": "The part or component of the affected system that is broke",
"help_pager_severity": "Severity of the incident, must be one of: must be one of the following: 'critical', 'warning', 'error' or 'info'",
"help_webhook_username": "Leave empty for bearer auth or when authentication is not necessary.",
"help_webhook_password": "Specify a password or bearer token",
"help_pager_description": "Shows up as description in pagerduty",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
"help_opsgenie_description": "Shows up as description in opsgenie",
"channel_creation_done": "Successfully created the channel",
"channel_creation_failed": "An unexpected error occurred while creating this channel",
"channel_edit_done": "Channels Edited Successfully",
"channel_edit_failed": "An unexpected error occurred while updating this channel",
"selected_channel_invalid": "Channel type selected is invalid",
"username_no_password": "A Password must be provided with user name",
"test_unsupported": "Sorry, this channel type does not support test yet",
"channel_test_done": "An alert has been sent to this channel",
"channel_test_failed": "Failed to send a test message to this channel, please confirm that the parameters are set correctly",
"channel_test_unexpected": "An unexpected error occurred while sending a message to this channel, please try again",
"webhook_url_required": "Webhook URL is mandatory",
"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"
}

View File

@@ -1454,9 +1454,10 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('lets a VIEWER reach /alerts/channels/new, which authz then gates', () => {
// CHANNELS_NEW runs on fine-grained authz, so the router no longer decides
// on the role: the page's own guard denies when `create` is not granted.
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1465,7 +1466,7 @@ describe('PrivateRoute', () => {
},
});
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started-with-signoz-cloud route', () => {
@@ -1549,11 +1550,6 @@ describe('PrivateRoute', () => {
keyof typeof routeWithInitialAuthZSupport,
AuthzRouteCase
> = {
CHANNELS_NEW: { path: ROUTES.CHANNELS_NEW, deniedRoles: DENIED_ROLES },
CHANNELS_EDIT: {
path: ROUTES.CHANNELS_EDIT.replace(':channelId', 'channel-id-1'),
deniedRoles: DENIED_ROLES,
},
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
DASHBOARD: {
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),

View File

@@ -1,28 +0,0 @@
import ROUTES from 'constants/routes';
import routes from '../routes';
/**
* The channel form renders inside the alerts page, which owns the tab strip.
* Pointing these routes at the standalone pages tears that page down on every
* open and rebuilds it on the way back, which reads as a full reload.
*/
describe('channel routes', () => {
const findRoute = (key: string): (typeof routes)[number] | undefined =>
routes.find((route) => route.key === key);
it('mounts the same component as the alerts list', () => {
const list = findRoute('LIST_ALL_ALERT');
const create = findRoute('CHANNELS_NEW');
const edit = findRoute('CHANNELS_EDIT');
expect(list?.component).toBeDefined();
expect(create?.component).toBe(list?.component);
expect(edit?.component).toBe(list?.component);
});
it('keeps the channel paths under /alerts', () => {
expect(ROUTES.CHANNELS_NEW.startsWith('/alerts')).toBe(true);
expect(ROUTES.CHANNELS_EDIT.startsWith('/alerts')).toBe(true);
});
});

View File

@@ -7,6 +7,8 @@ import {
AlertOverview,
AllErrors,
ApiMonitoring,
ChannelsEdit,
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardPanelEditorPage,
@@ -240,14 +242,14 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: ListAllALertsPage,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: ListAllALertsPage,
component: ChannelsEdit,
isPrivate: true,
key: 'CHANNELS_EDIT',
},

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,43 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,48 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,41 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,59 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
/**
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const create = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/channels', {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default create;

View File

@@ -0,0 +1,30 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/delete';
/**
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const deleteChannel = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default deleteChannel;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editEmail';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
email_configs: [
{
send_resolved: props.send_resolved,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editEmail;

View File

@@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
msteamsv2_configs: [
{
send_resolved: props.send_resolved,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editMsTeams;

View File

@@ -0,0 +1,44 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
opsgenie_configs: [
{
send_resolved: props.send_resolved,
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editOpsgenie;

View File

@@ -0,0 +1,48 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editPager';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
pagerduty_configs: [
{
send_resolved: props.send_resolved,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editPager;

View File

@@ -0,0 +1,41 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editSlack';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
slack_configs: [
{
send_resolved: props.send_resolved,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editSlack;

View File

@@ -0,0 +1,59 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
/**
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const editWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.put<PayloadProps>(`/channels/${props.id}`, {
name: props.name,
webhook_configs: [
{
send_resolved: props.send_resolved,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default editWebhook;

View File

@@ -0,0 +1,29 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/get';
import { Channels } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
try {
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default get;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { Channels, PayloadProps } from 'types/api/channels/getAll';
/**
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
* `api/generated/services/channels` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
try {
const response = await axios.get<PayloadProps>('/channels');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default getAll;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createEmail';
const testEmail = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
email_configs: [
{
send_resolved: true,
to: props.to,
html: props.html,
headers: props.headers,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testEmail;

View File

@@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
const testMsTeams = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
msteamsv2_configs: [
{
send_resolved: true,
webhook_url: props.webhook_url,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testMsTeams;

View File

@@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
const testOpsgenie = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
opsgenie_configs: [
{
api_key: props.api_key,
description: props.description,
priority: props.priority,
message: props.message,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testOpsgenie;

View File

@@ -0,0 +1,41 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createPager';
const testPager = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
pagerduty_configs: [
{
send_resolved: true,
routing_key: props.routing_key,
client: props.client,
client_url: props.client_url,
description: props.description,
severity: props.severity,
class: props.class,
component: props.component,
group: props.group,
details: {
...props.detailsArray,
},
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testPager;

View File

@@ -0,0 +1,34 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createSlack';
const testSlack = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
slack_configs: [
{
send_resolved: true,
api_url: props.api_url,
channel: props.channel,
title: props.title,
text: props.text,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testSlack;

View File

@@ -0,0 +1,52 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
const testWebhook = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
let httpConfig = {};
const username = props.username ? props.username.trim() : '';
const password = props.password ? props.password.trim() : '';
if (username !== '' && password !== '') {
httpConfig = {
basic_auth: {
username,
password,
},
};
} else if (username === '' && password !== '') {
httpConfig = {
authorization: {
type: 'Bearer',
credentials: password,
},
};
}
const response = await axios.post<PayloadProps>('/testChannel', {
name: props.name,
webhook_configs: [
{
send_resolved: true,
url: props.api_url,
http_config: httpConfig,
},
],
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default testWebhook;

View File

@@ -872,55 +872,6 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
}
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTOKind {
telegram = 'telegram',
}
export interface AlertmanagertypesChannelTelegramConfigDTO {
/**
* @type string
*/
apiUrl?: string;
/**
* @type string
* @format password
*/
botToken: string;
/**
* @type integer
* @format int64
*/
chatId: number;
/**
* @type boolean
*/
disableNotifications?: boolean;
/**
* @type string
*/
message?: string;
/**
* @type integer
*/
messageThreadId?: number;
/**
* @type boolean,null
*/
sendResolved?: boolean | null;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO {
/**
* @enum telegram
* @type string
*/
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTOKind;
spec: AlertmanagertypesChannelTelegramConfigDTO;
}
export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
@@ -931,8 +882,7 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO;
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
@@ -952,7 +902,6 @@ export enum AlertmanagertypesChannelKindDTO {
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
telegram = 'telegram',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
@@ -2513,7 +2462,7 @@ export interface ConfigSNSConfigDTO {
topic_arn?: string;
}
export interface AlertmanagertypesTelegramReceiverConfigDTO {
export interface ConfigTelegramConfigDTO {
api_url?: ConfigURLType2DTO;
/**
* @type integer
@@ -2545,10 +2494,6 @@ export interface AlertmanagertypesTelegramReceiverConfigDTO {
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
@@ -2754,7 +2699,7 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
telegram_configs?: ConfigTelegramConfigDTO[];
/**
* @type array
*/
@@ -2901,7 +2846,7 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
telegram_configs?: ConfigTelegramConfigDTO[];
/**
* @type array
*/
@@ -4477,48 +4422,6 @@ export interface ConfigJiraConfigDTO {
wont_fix_resolution?: string;
}
export interface ConfigTelegramConfigDTO {
api_url?: ConfigURLType2DTO;
/**
* @type integer
* @format int64
*/
chat?: number;
/**
* @type string
*/
chat_file?: string;
/**
* @type boolean
*/
disable_notifications?: boolean;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
message?: string;
/**
* @type integer
*/
message_thread_id?: number;
/**
* @type string
*/
parse_mode?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
token?: string;
/**
* @type string
*/
token_file?: string;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -5035,6 +4938,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5792,6 +5701,126 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @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.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5802,7 +5831,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6742,6 +6772,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -278,6 +279,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -1,6 +1,5 @@
.breadcrumb {
/* the bar sat flush against the tab strip above it */
padding: var(--spacing-4) 16px var(--spacing-2);
padding-left: 16px;
ol {
align-items: center;

View File

@@ -518,7 +518,10 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"
placeholder={
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
'Enter limit'
}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>

View File

@@ -43,7 +43,9 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}

View File

@@ -95,6 +95,7 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],

View File

@@ -9,6 +9,7 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
@@ -169,6 +170,33 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('shows the configured limit placeholder', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.SCATTER}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'defaulted',
placeholder: 'Default 10,000',
},
}}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10,000',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -0,0 +1,45 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -57,6 +57,19 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it('keeps a defaulted field as is, carrying its placeholder', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
placeholder: 'Default 10',
});
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -22,8 +22,12 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to.
*/
export type QueryBuilderFieldRule =
| { state: 'defaulted'; placeholder: string }
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };

View File

@@ -10,6 +10,7 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -20,6 +21,8 @@ const AVAILABLE: ResolvedQueryBuilderField = {
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'defaulted':
return { ...AVAILABLE, placeholder: rule.placeholder };
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',

View File

@@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath } from 'react-router-dom';
import { Button } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import Delete from './Delete';
function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const { t } = useTranslation(['channels']);
const { notifications } = useNotifications();
const { user } = useAppContext();
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
);
}, []);
const columns: ColumnsType<Channels> = [
{
title: t('column_channel_name'),
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: t('column_channel_type'),
dataIndex: 'type',
key: 'type',
width: 80,
},
];
if (action) {
columns.push({
title: t('column_channel_action'),
dataIndex: 'id',
key: 'action',
align: 'center',
width: 80,
render: (id: string): JSX.Element => (
<>
<Button onClick={(): void => onClickEditHandler(id)} type="link">
{t('column_channel_edit')}
</Button>
<Delete id={id} notifications={notifications} />
</>
),
});
}
return (
<ResizeTable
columns={columns}
dataSource={allChannels}
rowKey="id"
bordered
/>
);
}
interface AlertChannelsProps {
allChannels: Channels[];
}
export default AlertChannels;

View File

@@ -0,0 +1,4 @@
.alert-channels-container {
width: 100%;
padding: 0 var(--spacing-8);
}

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import deleteChannel from 'api/channels/delete';
import APIError from 'types/api/error';
function Delete({ notifications, id }: DeleteProps): JSX.Element {
const { t } = useTranslation(['channels']);
const [loading, setLoading] = useState(false);
const queryClient = useQueryClient();
const onClickHandler = async (): Promise<void> => {
try {
setLoading(true);
await deleteChannel({
id,
});
notifications.success({
message: 'Success',
description: t('channel_delete_success'),
});
// Invalidate and refetch
queryClient.invalidateQueries(['getChannels']);
setLoading(false);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setLoading(false);
}
};
return (
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
);
}
interface DeleteProps {
notifications: NotificationInstance;
id: string;
}
export default Delete;

View File

@@ -0,0 +1,84 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', () => {
expect(screen.getByText('sending_channels_note')).toBeInTheDocument();
});
it('Should check if "New Alert Channel" Button is visble', () => {
expect(screen.getByText('button_new_channel')).toBeInTheDocument();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.getByText('column_channel_action')).toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.getAllByText('column_channel_edit')[0]).toBeInTheDocument();
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
});
it('Should check if clicking on Delete displays Success Toast "Channel Deleted Successfully"', async () => {
const deleteButton = screen.getAllByRole('button', { name: 'Delete' })[0];
expect(deleteButton).toBeInTheDocument();
act(() => {
fireEvent.click(deleteButton);
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_delete_success',
});
});
});
});
});

View File

@@ -0,0 +1,78 @@
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
jest.mock('hooks/useComponentPermission', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => [false]),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
}),
}));
describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible', async () => {
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
);
});
it('Should check if "New Alert Channel" Button is visble and disabled', async () => {
const newAlertButton = screen.getByRole('button', {
name: /button_new_channel/i,
});
await waitFor(() => expect(newAlertButton).toBeInTheDocument());
expect(newAlertButton).toBeDisabled();
});
it('Should check if the help icon is visible and displays "tooltip_notification_channels', async () => {
const helpIcon = screen.getByRole('img', { name: /help/i });
fireEvent.mouseOver(helpIcon);
await waitFor(() => {
const tooltip = screen.getByText('tooltip_notification_channels');
expect(tooltip).toBeInTheDocument();
});
});
});
describe('Should check if the channels table is properly displayed', () => {
it('Should check if the table columns are properly displayed', async () => {
expect(screen.getByText('column_channel_name')).toBeInTheDocument();
expect(screen.getByText('column_channel_type')).toBeInTheDocument();
expect(screen.queryByText('column_channel_action')).not.toBeInTheDocument();
});
it('Should check if the data in the table is displayed properly', async () => {
expect(screen.getByText('Dummy-Channel')).toBeInTheDocument();
expect(screen.getAllByText('slack')[0]).toBeInTheDocument();
expect(screen.queryByText('column_channel_edit')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,914 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
GoogleChatInitialConfig,
IncidentIOInitialConfig,
JiraInitialConfig,
JsmOpsInitialConfig,
} from 'container/CreateAlertChannels/defaults';
import {
googleChatDescriptionDefaultValue,
googleChatTitleDefaultValue,
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Slack} />);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should check if the title is "New Notification Channels"', () => {
expect(screen.getByText('page_title_create')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
// Default Channel type (Slack) fields
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
it('Should check if saving the form without filling the name displays error notification', async () => {
const saveButton = screen.getByRole('button', {
name: 'button_save_channel',
});
fireEvent.click(saveButton);
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'channel_name_required',
}),
);
});
it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => {
server.use(
rest.post('http://localhost/api/v1/testChannel', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: 'test alert sent',
}),
),
),
);
const testButton = screen.getByRole('button', {
name: 'button_test_channel',
});
fireEvent.click(testButton);
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_test_done',
}),
);
});
it('Should check if clicking on Test button shows "Something went wrong" error message if testing fails', async () => {
const testButton = screen.getByRole('button', {
name: 'button_test_channel',
});
act(() => {
fireEvent.click(testButton);
});
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
describe('Webhook', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Webhook} />);
});
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
expect(screen.getByText('Webhook')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_username',
testId: 'webhook-username-textbox',
helpText: 'help_webhook_username',
});
});
it('Should check if Password label and textbox, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'Password (optional)',
testId: 'webhook-password-textbox',
helpText: 'help_webhook_password',
});
});
});
describe('PagerDuty', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
});
it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => {
expect(screen.getByText('Pagerduty')).toBeInTheDocument();
});
it('Should check if Routing key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_routing_key',
testId: 'pager-routing-key-textbox',
});
});
it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_description',
testId: 'pager-description-textarea',
helpText: 'help_pager_description',
});
});
it('Should check if the description contains default template', () => {
const descriptionTextArea = screen.getByTestId(
'pager-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_severity',
testId: 'pager-severity-textbox',
helpText: 'help_pager_severity',
});
});
it('Should check if Severity contains the default template', () => {
const severityTextbox = screen.getByTestId('pager-severity-textbox');
expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue);
});
it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_details',
testId: 'pager-additional-details-textarea',
helpText: 'help_pager_details',
});
});
it('Should check if Additional Information contains the default template', () => {
const detailsTextArea = screen.getByTestId(
'pager-additional-details-textarea',
);
expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue);
});
it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_group',
testId: 'pager-group-textarea',
helpText: 'help_pager_group',
});
});
it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_class',
testId: 'pager-class-textarea',
helpText: 'help_pager_class',
});
});
it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client',
testId: 'pager-client-textarea',
helpText: 'help_pager_client',
});
});
it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => {
const clientTextArea = screen.getByTestId('pager-client-textarea');
expect(clientTextArea).toHaveValue('SigNoz Alert Manager');
});
it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client_url',
testId: 'pager-client-url-textarea',
helpText: 'help_pager_client_url',
});
});
it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => {
const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea');
expect(clientUrlTextArea).toHaveValue(
'https://enter-signoz-host-n-port-here/alerts',
);
});
});
describe('Opsgenie', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
});
it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => {
expect(screen.getByText('Opsgenie')).toBeInTheDocument();
});
it('Should check if API key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_api_key',
testId: 'opsgenie-api-key-textbox',
required: true,
});
});
it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_message',
testId: 'opsgenie-message-textarea',
helpText: 'help_opsgenie_message',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_description',
testId: 'opsgenie-description-textarea',
helpText: 'help_opsgenie_description',
required: true,
});
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
const descriptionTextArea = screen.getByTestId(
'opsgenie-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
opsGenieDescriptionDefaultValue,
);
});
it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_priority',
testId: 'opsgenie-priority-textarea',
helpText: 'help_opsgenie_priority',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea');
expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue);
});
});
describe('Email', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Email} />);
});
it('Should check if the selected item in the type dropdown has text "Email"', () => {
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_email_to',
testId: 'email-to-textbox',
helpText: 'help_email_to',
required: true,
});
});
});
describe('Microsoft Teams', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.MsTeams} />);
});
it('Should check if the selected item in the type dropdown has text "msteams"', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
});
describe('Google Chat', () => {
const validWebhookUrl =
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
});
// paste instead of type: a per-keystroke re-render of the whole form
// pushes these tests past the 5s jest timeout on slower CI runners
async function fillField(
user: ReturnType<typeof userEvent.setup>,
testId: string,
value: string,
): Promise<void> {
await user.click(screen.getByTestId(testId));
await user.paste(value);
}
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
expect(screen.getByText('Google Chat')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Title contains the google chat template', () => {
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
);
});
it('Should check if Description contains the google chat template', () => {
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
});
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
const user = userEvent.setup();
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', 'https://example.com/webhook');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'google_chat_webhook_url_invalid',
}),
);
});
it('Should check if saving sends a googlechat_configs payload', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await fillField(user, 'channel-name-textbox', 'gchat-channel');
await fillField(user, 'webhook-url-textbox', validWebhookUrl);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'gchat-channel',
googlechat_configs: [
{
webhook_url: validWebhookUrl,
title: GoogleChatInitialConfig.title,
text: GoogleChatInitialConfig.text,
send_resolved: true,
},
],
});
});
});
describe('Jira', () => {
const validSite = 'https://acme.atlassian.net';
const fillRequired = async (
user: ReturnType<typeof userEvent.setup>,
site: string,
): Promise<void> => {
await user.type(screen.getByTestId('channel-name-textbox'), 'jira-channel');
await user.type(screen.getByTestId('jira-site-textbox'), site);
await user.type(screen.getByTestId('jira-email-textbox'), 'me@acme.com');
await user.type(screen.getByTestId('jira-api-token-textbox'), 'tok123');
await user.type(screen.getByTestId('jira-project-textbox'), 'KAN');
};
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Jira} />);
});
it('Should check if the selected item in the type dropdown has text "Jira"', () => {
expect(screen.getByText('Jira')).toBeInTheDocument();
});
it('Should check if the Site URL field is displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jira_site',
testId: 'jira-site-textbox',
});
});
it('Should prefill the issue type with Task', () => {
expect(screen.getByTestId('jira-issue-type-textbox')).toHaveValue('Task');
});
it('Should show the service-account recommendation tip linking to the docs', () => {
expect(screen.getByTestId('jira-service-account-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jira_service_account_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended',
);
});
it('Should display an error when the site is not an atlassian.net URL', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, 'https://example.com');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_site_invalid',
}),
);
}, 15000);
it('Should send a jira_configs payload with basic auth', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(
screen.getByTestId('jira-wont-fix-resolution-textbox'),
"Won't Do",
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: validSite,
project: 'KAN',
issue_type: 'Task',
summary: JiraInitialConfig.summary,
description: JiraInitialConfig.description,
send_resolved: true,
wont_fix_resolution: "Won't Do",
http_config: {
basic_auth: { username: 'me@acme.com', password: 'tok123' },
},
},
],
});
}, 15000);
it('Should block save when the reopen window is below the 1m minimum', async () => {
const user = userEvent.setup({ delay: null });
await fillRequired(user, validSite);
await user.click(screen.getByText('jira_advanced_section'));
await user.type(screen.getByTestId('jira-reopen-duration-textbox'), '30s');
// the rule surfaces an inline message, not just a red border
await expect(
screen.findByText('jira_reopen_duration_invalid'),
).resolves.toBeInTheDocument();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'jira_reopen_duration_invalid',
}),
);
}, 15000);
});
describe('JSM Ops', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.JsmOps} />);
});
it('Should show "Jira Service Management Ops" as the selected type', () => {
expect(screen.getByText('Jira Service Management Ops')).toBeInTheDocument();
});
it('Should display the API key field properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_jsmops_api_key',
testId: 'jsmops-api-key-textbox',
});
});
it('Should show the tip linking to the JSM Ops docs', () => {
expect(screen.getByTestId('jsmops-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'jsmops_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/',
);
});
it('Should block save when the API key is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'api_key_required',
}),
);
});
it('Should send a jsmops_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'jsmops-channel',
);
await user.type(screen.getByTestId('jsmops-api-key-textbox'), 'key-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'jsmops-channel',
jsmops_configs: [
{
api_key: 'key-abc',
send_resolved: true,
message: JsmOpsInitialConfig.message,
description: JsmOpsInitialConfig.description,
priority: JsmOpsInitialConfig.priority,
tags: JsmOpsInitialConfig.tags?.join(','),
},
],
});
});
});
describe('incident.io', () => {
const incidentIOURL =
'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV';
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.IncidentIO} />);
});
it('Should display the URL and token fields with the docs tip', () => {
testLabelInputAndHelpValue({
labelText: 'field_incidentio_url',
testId: 'incidentio-url-textbox',
});
testLabelInputAndHelpValue({
labelText: 'field_incidentio_token',
testId: 'incidentio-token-textbox',
});
expect(screen.getByTestId('incidentio-tip')).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'incidentio_tip_link' }),
).toHaveAttribute(
'href',
'https://signoz.io/docs/alerts-management/notification-channel/incidentio/',
);
});
it('Should block save when the URL or token is missing', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'incidentio_required_fields',
}),
);
});
it('Should display an error when the URL is not an alert events URL', async () => {
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.type(
screen.getByTestId('incidentio-url-textbox'),
'https://api.incident.io/v2/incidents',
);
await user.type(screen.getByTestId('incidentio-token-textbox'), 'tok-abc');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(errorNotification).toHaveBeenCalledWith({
message: 'Error',
description: 'incidentio_url_invalid',
}),
);
}, 15000);
it('Should send an incidentio_configs payload with prefilled defaults', async () => {
let requestBody: unknown;
server.use(
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
requestBody = await req.json();
return res(
ctx.status(201),
ctx.json({ status: 'success', data: 'channel created' }),
);
}),
);
const user = userEvent.setup();
await user.type(
screen.getByTestId('channel-name-textbox'),
'incidentio-channel',
);
await user.type(
screen.getByTestId('incidentio-url-textbox'),
incidentIOURL,
);
await user.type(screen.getByTestId('incidentio-token-textbox'), 'tok-abc');
await user.click(screen.getByTestId('incidentio-metadata-add'));
await user.type(screen.getByTestId('incidentio-metadata-key-0'), 'team');
await user.type(screen.getByTestId('incidentio-metadata-value-0'), 'core');
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'Success',
description: 'channel_creation_done',
}),
);
expect(requestBody).toStrictEqual({
name: 'incidentio-channel',
incidentio_configs: [
{
url: incidentIOURL,
token: 'tok-abc',
send_resolved: true,
title: IncidentIOInitialConfig.title,
description: IncidentIOInitialConfig.description,
metadata: { team: 'core' },
},
],
});
}, 15000);
});
describe('Changing the channel type', () => {
async function selectType(
user: ReturnType<typeof userEvent.setup>,
optionText: string,
): Promise<void> {
// the type dropdown opens on the inner search input of the antd select
await user.click(screen.getByRole('combobox'));
await user.click(await screen.findByTitle(optionText));
}
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Slack} />);
await selectType(user, 'Google Chat');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
googleChatTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
googleChatDescriptionDefaultValue,
);
await selectType(user, 'Slack');
await waitFor(() =>
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
slackTitleDefaultValue,
),
);
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
slackDescriptionDefaultValue,
);
});
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
const user = userEvent.setup();
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
await selectType(user, 'Pagerduty');
await waitFor(() =>
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
),
);
});
});
});
});

View File

@@ -0,0 +1,336 @@
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import {
opsGenieDescriptionDefaultValue,
opsGenieMessageDefaultValue,
opsGeniePriorityDefaultValue,
pagerDutyAdditionalDetailsDefaultValue,
pagerDutyDescriptionDefaultValue,
pagerDutySeverityTextDefaultValue,
slackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Create Alert Channel (Normal User)', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('Should check if the new alert channel is properly displayed with the cascading fields of slack channel', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Slack} />);
});
it('Should check if the title is "New Notification Channels"', () => {
expect(screen.getByText('page_title_create')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
// Default Channel type (Slack) fields
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
});
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
describe('Webhook', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Webhook} />);
});
it('Should check if the selected item in the type dropdown has text "Webhook"', () => {
expect(screen.getByText('Webhook')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
});
});
it('Should check if Webhook User Name label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_username',
testId: 'webhook-username-textbox',
helpText: 'help_webhook_username',
});
});
it('Should check if Password label and textbox, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'Password (optional)',
testId: 'webhook-password-textbox',
helpText: 'help_webhook_password',
});
});
});
describe('PagerDuty', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Pagerduty} />);
});
it('Should check if the selected item in the type dropdown has text "Pagerduty"', () => {
expect(screen.getByText('Pagerduty')).toBeInTheDocument();
});
it('Should check if Routing key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_routing_key',
testId: 'pager-routing-key-textbox',
});
});
it('Should check if Description label, required, info (Shows up as description in pagerduty), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_description',
testId: 'pager-description-textarea',
helpText: 'help_pager_description',
});
});
it('Should check if the description contains default template', () => {
const descriptionTextArea = screen.getByTestId(
'pager-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
pagerDutyDescriptionDefaultValue,
);
});
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_severity',
testId: 'pager-severity-textbox',
helpText: 'help_pager_severity',
});
});
it('Should check if Severity contains the default template', () => {
const severityTextbox = screen.getByTestId('pager-severity-textbox');
expect(severityTextbox).toHaveValue(pagerDutySeverityTextDefaultValue);
});
it('Should check if Additional Information label, text area, and help text (help_pager_details) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_details',
testId: 'pager-additional-details-textarea',
helpText: 'help_pager_details',
});
});
it('Should check if Additional Information contains the default template', () => {
const detailsTextArea = screen.getByTestId(
'pager-additional-details-textarea',
);
expect(detailsTextArea).toHaveValue(pagerDutyAdditionalDetailsDefaultValue);
});
it('Should check if Group label, text area, and info (help_pager_group) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_group',
testId: 'pager-group-textarea',
helpText: 'help_pager_group',
});
});
it('Should check if Class label, text area, and info (help_pager_class) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_class',
testId: 'pager-class-textarea',
helpText: 'help_pager_class',
});
});
it('Should check if Client label, text area, and info (Shows up as event source in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client',
testId: 'pager-client-textarea',
helpText: 'help_pager_client',
});
});
it('Should check if Client input contains the default value "SigNoz Alert Manager"', () => {
const clientTextArea = screen.getByTestId('pager-client-textarea');
expect(clientTextArea).toHaveValue('SigNoz Alert Manager');
});
it('Should check if Client URL label, text area, and info (Shows up as event source link in Pagerduty) are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_pager_client_url',
testId: 'pager-client-url-textarea',
helpText: 'help_pager_client_url',
});
});
it('Should check if Client URL contains the default value "https://enter-signoz-host-n-port-here/alerts"', () => {
const clientUrlTextArea = screen.getByTestId('pager-client-url-textarea');
expect(clientUrlTextArea).toHaveValue(
'https://enter-signoz-host-n-port-here/alerts',
);
});
});
describe('Opsgenie', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
});
it('Should check if the selected item in the type dropdown has text "Opsgenie"', () => {
expect(screen.getByText('Opsgenie')).toBeInTheDocument();
});
it('Should check if API key label, required, and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_api_key',
testId: 'opsgenie-api-key-textbox',
required: true,
});
});
it('Should check if Message label, required, info (Shows up as message in opsgenie), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_message',
testId: 'opsgenie-message-textarea',
helpText: 'help_opsgenie_message',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const messageTextArea = screen.getByTestId('opsgenie-message-textarea');
expect(messageTextArea).toHaveValue(opsGenieMessageDefaultValue);
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_description',
testId: 'opsgenie-description-textarea',
helpText: 'help_opsgenie_description',
required: true,
});
});
it('Should check if Description label, required, info (Shows up as description in opsgenie), and text area are displayed properly `{{ if gt (len .Alerts.Firing) 0 -}}', () => {
const descriptionTextArea = screen.getByTestId(
'opsgenie-description-textarea',
);
expect(descriptionTextArea).toHaveTextContent(
opsGenieDescriptionDefaultValue,
);
});
it('Should check if Priority label, required, info (help_opsgenie_priority), and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_opsgenie_priority',
testId: 'opsgenie-priority-textarea',
helpText: 'help_opsgenie_priority',
required: true,
});
});
it('Should check if Message contains the default template', () => {
const priorityTextArea = screen.getByTestId('opsgenie-priority-textarea');
expect(priorityTextArea).toHaveValue(opsGeniePriorityDefaultValue);
});
});
describe('Email', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.Email} />);
});
it('Should check if the selected item in the type dropdown has text "Email"', () => {
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('Should check if API key label, required, info(help_email_to), and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_email_to',
testId: 'email-to-textbox',
helpText: 'help_email_to',
required: true,
});
});
});
describe('Microsoft Teams', () => {
beforeEach(() => {
render(<CreateAlertChannels preType={ChannelType.MsTeams} />);
});
it('Should check if the selected item in the type dropdown has text "Microsoft Teams"', () => {
expect(screen.getByText('Microsoft Teams')).toBeInTheDocument();
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'button_test_channel' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'button_return' }),
).toBeInTheDocument();
});
it.skip('Should check if save and test buttons are disabled', () => {
expect(
screen.getByRole('button', { name: 'button_save_channel' }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: 'button_test_channel' }),
).toBeDisabled();
});
});
});
});

View File

@@ -0,0 +1,120 @@
import EditAlertChannels from 'container/EditAlertChannels';
import {
editAlertChannelInitialValue,
editSlackDescriptionDefaultValue,
slackTitleDefaultValue,
} from 'mocks-server/__mockdata__/alerts';
import { render, screen } from 'tests/test-utils';
import { testLabelInputAndHelpValue } from './testUtils';
const successNotification = jest.fn();
const errorNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: errorNotification,
},
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
describe('Should check if the edit alert channel is properly displayed', () => {
beforeEach(() => {
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Should check if the title is "Edit Notification Channels"', () => {
expect(screen.getByText('page_title_edit')).toBeInTheDocument();
});
it('Should check if the name label and textbox are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_name',
testId: 'channel-name-textbox',
value: 'Dummy-Channel',
});
});
it('Should check if Send resolved alerts label and checkbox are displayed properly and the checkbox is checked', () => {
testLabelInputAndHelpValue({
labelText: 'field_send_resolved',
testId: 'field-send-resolved-checkbox',
});
expect(screen.getByTestId('field-send-resolved-checkbox')).toBeChecked();
});
it('Should check if channel type label and dropdown are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_channel_type',
testId: 'channel-type-select',
});
});
it('Should check if the selected item in the type dropdown has text "Slack"', () => {
expect(screen.getByText('Slack')).toBeInTheDocument();
});
it('Should check if Webhook URL label and input are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_webhook_url',
testId: 'webhook-url-textbox',
value:
'https://discord.com/api/webhooks/dummy_webhook_id/dummy_webhook_token/slack',
});
});
it('Should check if Recepient label, input, and help text are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_recipient',
testId: 'slack-channel-textbox',
helpText: 'slack_channel_help',
value: '#dummy_channel',
});
});
it('Should check if Title label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_title',
testId: 'title-textarea',
});
});
it('Should check if Title contains template', () => {
const titleTextArea = screen.getByTestId('title-textarea');
expect(titleTextArea).toHaveTextContent(slackTitleDefaultValue);
});
it('Should check if Description label and text area are displayed properly', () => {
testLabelInputAndHelpValue({
labelText: 'field_slack_description',
testId: 'description-textarea',
});
});
it('Should check if Description contains template', () => {
const descriptionTextArea = screen.getByTestId('description-textarea');
expect(descriptionTextArea).toHaveTextContent(
editSlackDescriptionDefaultValue,
);
});
it('Should check if the form buttons are displayed properly (Save, Test, Back)', () => {
expect(screen.getByText('button_save_channel')).toBeInTheDocument();
expect(screen.getByText('button_test_channel')).toBeInTheDocument();
expect(screen.getByText('button_return')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,186 @@
import EditAlertChannels from 'container/EditAlertChannels';
import { editAlertChannelInitialValue } from 'mocks-server/__mockdata__/alerts';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: { success: jest.fn(), error: jest.fn() },
})),
}));
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
}));
interface EditRequest {
id: string;
body: { name: string; slack_configs: { send_resolved: boolean }[] };
}
// Captures the PUT /channels/:id request the edit form fires, so assertions can
// run against the real HTTP payload instead of a hand-mocked api client.
function mockEditChannel(): { calls: EditRequest[] } {
const result: { calls: EditRequest[] } = { calls: [] };
server.use(
rest.put('http://localhost/api/v1/channels/:id', async (req, res, ctx) => {
result.calls.push({
id: req.params.id as string,
body: await req.json(),
});
return res(
ctx.status(200),
ctx.json({ status: 'success', data: 'channel updated' }),
);
}),
);
return result;
}
describe('EditAlertChannels save', () => {
afterEach(() => jest.clearAllMocks());
it('sends the channelId in the edit request (regression: empty id)', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
});
it('blocks jira save when the reopen window is below the 1m minimum', async () => {
const edit = mockEditChannel();
const jiraInitialValue = {
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
reopen_duration: '30s',
};
const { unmount } = render(
<EditAlertChannels channelId="3" initialValue={jiraInitialValue} />,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
expect(edit.calls).toHaveLength(0);
unmount();
render(
<EditAlertChannels
channelId="3"
initialValue={{ ...jiraInitialValue, reopen_duration: '72h' }}
/>,
);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
});
it('preserves the jira wont-fix resolution on save', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={{
type: 'jira',
name: 'jira-channel',
site: 'https://acme.atlassian.net',
username: 'user@acme.io',
password: 'token',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
wont_fix_resolution: "Won't Do",
}}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].body).toStrictEqual({
name: 'jira-channel',
jira_configs: [
{
site: 'https://acme.atlassian.net',
project: 'OPS',
issue_type: 'Task',
send_resolved: true,
wont_fix_resolution: "Won't Do",
http_config: {
basic_auth: { username: 'user@acme.io', password: 'token' },
},
},
],
});
});
it('sends an incidentio_configs payload when editing an incident.io channel', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="4"
initialValue={{
type: 'incidentio',
name: 'incidentio-channel',
url: 'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV',
token: 'tok-abc',
send_resolved: true,
metadata: { env: 'prod' },
}}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('4');
expect(edit.calls[0].body).toStrictEqual({
name: 'incidentio-channel',
incidentio_configs: [
{
url: 'https://api.incident.io/v2/alert_events/http/01M0D1JNVBGBGVTWX053EM12XV',
token: 'tok-abc',
send_resolved: true,
metadata: { env: 'prod' },
},
],
});
});
it('persists send_resolved toggle in the edit request', async () => {
const edit = mockEditChannel();
render(
<EditAlertChannels
channelId="3"
initialValue={editAlertChannelInitialValue}
/>,
);
const user = userEvent.setup();
const sendResolved = screen.getByTestId('field-send-resolved-checkbox');
expect(sendResolved).toBeChecked();
await user.click(sendResolved);
await user.click(screen.getByTestId('save-channel-button'));
await waitFor(() => expect(edit.calls).toHaveLength(1));
expect(edit.calls[0].id).toBe('3');
expect(edit.calls[0].body.slack_configs[0].send_resolved).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
import { screen } from 'tests/test-utils';
export const testLabelInputAndHelpValue = ({
labelText,
testId,
helpText,
required = false,
value,
}: {
labelText: string;
testId: string;
helpText?: string;
required?: boolean;
value?: string;
}): void => {
const label = screen.getByText(labelText);
expect(label).toBeInTheDocument();
const input = screen.getByTestId(testId);
expect(input).toBeInTheDocument();
if (helpText !== undefined) {
expect(screen.getByText(helpText)).toBeInTheDocument();
}
if (required) {
expect(input).toBeRequired();
}
if (value) {
expect(input).toHaveValue(value);
}
};

View File

@@ -0,0 +1,95 @@
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Tooltip, Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes';
import useComponentPermission from 'hooks/useComponentPermission';
import history from 'lib/history';
import { isUndefined } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import AlertChannelsComponent from './AlertChannels';
import { Button, ButtonContainer, RightActionContainer } from './styles';
import './AllAlertChannels.styles.scss';
const { Text } = Typography;
function AlertChannels(): JSX.Element {
const { t } = useTranslation(['channels']);
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const onToggleHandler = useCallback(() => {
history.push(ROUTES.CHANNELS_NEW);
}, []);
const { isLoading, data, error } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
useEffect(() => {
if (!isUndefined(data?.data)) {
logEvent('Alert Channel: Channel list page visited', {
number: data?.data?.length,
});
}
}, [data?.data]);
if (error) {
return <Typography>{error.getErrorMessage()}</Typography>;
}
if (isLoading || isUndefined(data?.data)) {
return <Spinner tip={t('loading_channels_message')} height="90vh" />;
}
return (
<div className="alert-channels-container">
<ButtonContainer>
<Text truncate={1} color="muted">
{t('sending_channels_note')}
</Text>
<RightActionContainer>
<TextToolTip
text={t('tooltip_notification_channels')}
url="https://signoz.io/docs/setup-alerts-notification/"
/>
<Tooltip
title={
!addNewChannelPermission
? 'Ask an admin to create alert channel'
: undefined
}
>
<Button onClick={onToggleHandler} disabled={!addNewChannelPermission}>
<Flex align="center" justify="center" gap={4}>
<Plus size="md" /> {t('button_new_channel')}
</Flex>
</Button>
</Tooltip>
</RightActionContainer>
</ButtonContainer>
<AlertChannelsComponent allChannels={data?.data || []} />
</div>
);
}
export default AlertChannels;

View File

@@ -0,0 +1,26 @@
import { Button as ButtonComponent } from 'antd';
import styled from 'styled-components';
export const RightActionContainer = styled.div`
&&& {
display: flex;
align-items: center;
}
`;
export const ButtonContainer = styled.div`
&&& {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
margin-bottom: 1rem;
padding-right: 1rem;
}
`;
export const Button = styled(ButtonComponent)`
&&& {
margin-left: 1rem;
}
`;

View File

@@ -0,0 +1,13 @@
.create-alert-channels-container {
width: 100%;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;
padding: 16px;
.form-alert-channels-title {
margin-top: 0px;
margin-bottom: 16px;
}
}

View File

@@ -1,148 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toPostableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelFormValues, ChannelKind } from './types';
const slackValues: ChannelFormValues = {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
};
describe('toChannelConfig', () => {
it('sends every field the slack spec models, new ones included', () => {
expect(toChannelConfig(ChannelKind.slack, slackValues)).toStrictEqual({
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#alerts',
title: 'title template',
titleLink: 'https://signoz.io',
text: 'body template',
pretext: 'pretext',
fallback: 'fallback',
footer: 'footer',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
});
});
it('drops what a user left blank but keeps a false they chose', () => {
expect(
toChannelConfig(ChannelKind.webhook, {
url: 'https://example.com/hook',
username: '',
password: undefined,
bearerToken: '',
sendResolved: false,
}),
).toStrictEqual({
kind: 'webhook',
spec: { url: 'https://example.com/hook', sendResolved: false },
});
});
it('drops an attachment list the user emptied instead of sending []', () => {
expect(
toChannelConfig(ChannelKind.slack, {
...slackValues,
fields: [],
actions: [],
}).spec,
).not.toHaveProperty('fields');
});
it.each(Object.values(ChannelKind))('stamps the config as %s', (kind) => {
expect(toChannelConfig(kind, slackValues).kind).toBe(kind);
});
it('sends only the selected kind s fields, not another kind s leftovers', () => {
const config = toChannelConfig(ChannelKind.msteams, {
webhookUrl: 'https://teams.example.com/hook',
title: 'shared title',
// left over from a kind the user switched away from
routingKey: 'pagerduty-key',
apiUrl: 'https://hooks.slack.com/services/T/B/X',
});
expect(config.spec).toStrictEqual({
webhookUrl: 'https://teams.example.com/hook',
title: 'shared title',
});
});
});
describe('toPostableChannel', () => {
it('lets the api generate the immutable name from the display name', () => {
expect(toPostableChannel(ChannelKind.slack, slackValues)).toMatchObject({
generateName: true,
displayName: 'prod alerts',
});
});
it('leaves the display name out of the spec', () => {
expect(
toPostableChannel(ChannelKind.slack, slackValues).config.spec,
).not.toHaveProperty('name');
});
});
describe('toChannelFormState', () => {
it('round-trips a channel back into the form it was built from', () => {
const channel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: toChannelConfig(ChannelKind.slack, slackValues),
} as AlertmanagertypesGettableNotificationChannelDTO;
const { kind, values } = toChannelFormState(channel);
expect(kind).toBe(ChannelKind.slack);
expect(toChannelConfig(kind, values)).toStrictEqual(channel.config);
expect(values.name).toBe('prod alerts');
});
});
describe('jira reopen duration', () => {
it.each([
['72h', '3d'],
['1440m', '1d'],
['7d', '1w'],
['90m', '1h30m'],
['3d', '3d'],
])('sends %s as %s, the only spelling the api accepts back', (typed, sent) => {
expect(
toChannelConfig(ChannelKind.jira, {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'a@b.c',
apiToken: 'tok',
reopenDuration: typed,
}).spec,
).toMatchObject({ reopenDuration: sent });
});
it('leaves a duration it cannot parse for the validator to reject', () => {
expect(
toChannelConfig(ChannelKind.jira, { reopenDuration: 'tomorrow' }).spec,
).toMatchObject({ reopenDuration: 'tomorrow' });
});
});

View File

@@ -1,182 +0,0 @@
import {
AlertmanagertypesChannelConfigDTO,
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelTelegramConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { pick } from 'lodash-es';
import { omitBlank } from 'utils/omitBlank';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
import { canonicalJiraReopenDuration } from './utils';
/**
* The fields each kind sends. `satisfies` ties every entry to that kind's
* generated spec, so a key the API does not model fails to compile.
* An untouched field is dropped rather than sent empty, because the API
* applies its own default only for an absent key.
*/
const SPEC_FIELDS = {
[ChannelKind.slack]: [
'apiUrl',
'channel',
'title',
'titleLink',
'text',
'pretext',
'fallback',
'footer',
'color',
'fields',
'actions',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelSlackConfigDTO)[],
[ChannelKind.webhook]: [
'url',
'username',
'password',
'bearerToken',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelWebhookConfigDTO)[],
[ChannelKind.email]: [
'to',
'html',
'headers',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelEmailConfigDTO)[],
[ChannelKind.pagerduty]: [
'routingKey',
'client',
'clientUrl',
'description',
'severity',
'component',
'group',
'class',
'url',
'details',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelPagerdutyConfigDTO)[],
[ChannelKind.opsgenie]: [
'apiKey',
'apiUrl',
'message',
'description',
'source',
'priority',
'details',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelOpsgenieConfigDTO)[],
[ChannelKind.msteams]: [
'webhookUrl',
'title',
'text',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelMSTeamsConfigDTO)[],
[ChannelKind.googlechat]: [
'webhookUrl',
'title',
'text',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelGoogleChatConfigDTO)[],
[ChannelKind.jira]: [
'site',
'project',
'issueType',
'email',
'apiToken',
'summary',
'description',
'priority',
'labels',
'resolveTransition',
'reopenTransition',
'wontFixResolution',
'reopenDuration',
'customFields',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelJiraConfigDTO)[],
[ChannelKind.jsmops]: [
'apiKey',
'message',
'description',
'priority',
'tags',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelJSMOpsConfigDTO)[],
[ChannelKind.incidentio]: [
'url',
'token',
'title',
'description',
'metadata',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelIncidentIOConfigDTO)[],
[ChannelKind.telegram]: [
'botToken',
'chatId',
'apiUrl',
'title',
'message',
'messageThreadId',
'disableNotifications',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelTelegramConfigDTO)[],
} satisfies Record<ChannelKind, readonly (keyof ChannelSpecFormValues)[]>;
export function toChannelConfig(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesChannelConfigDTO {
const spec: Record<string, unknown> = omitBlank(
pick(values, SPEC_FIELDS[kind]),
);
// the API answers a read with the duration prometheus would print, and
// rejects anything spelled differently
if (kind === ChannelKind.jira && typeof spec.reopenDuration === 'string') {
spec.reopenDuration = canonicalJiraReopenDuration(spec.reopenDuration);
}
// The generated config type is a union whose every member fixes `kind` to a
// single literal, so a plain ChannelKind never matches one. Asserted once
// here instead of ten times.
return { kind, spec } as unknown as AlertmanagertypesChannelConfigDTO;
}
export function toPostableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesPostableNotificationChannelDTO {
return {
// the API derives the immutable dns1123 name from the display name
generateName: true,
displayName: values.name ?? '',
config: toChannelConfig(kind, values),
};
}
export function toUpdatableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesUpdatableNotificationChannelDTO {
return { config: toChannelConfig(kind, values) };
}
export function toTestableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesTestableNotificationChannelDTO {
return { config: toChannelConfig(kind, values) };
}

View File

@@ -1,25 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
export interface ChannelFormState {
kind: ChannelKind;
values: ChannelFormValues;
}
/**
* The form edits the spec the API returns, so loading a channel lifts its
* display name alongside that spec rather than remapping field by field.
*/
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
return {
kind: channel.config.kind as string as ChannelKind,
values: {
...channel.config.spec,
// the API keeps `name` immutable and exposes the editable label separately
name: channel.displayName,
},
};
}

View File

@@ -1,148 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toUpdatableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelKind } from './types';
/**
* Loading a channel and saving it again without touching anything must send the
* API exactly what it returned. A field the form model drops silently loses a
* user's configuration on the next save, because the update replaces the whole
* config.
*/
const SPECS: Record<string, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T/B/X',
channel: '#ops',
title: 'title',
titleLink: 'https://signoz.io',
text: 'body',
pretext: 'pre',
fallback: 'fall',
footer: 'foot',
color: 'danger',
fields: [{ title: 'env', value: 'prod', short: true }],
actions: [{ type: 'button', text: 'Runbook', url: 'https://runbook' }],
sendResolved: true,
},
webhook: {
url: 'https://example.com/hook',
username: 'u',
password: 'p',
bearerToken: 't',
sendResolved: true,
},
email: {
to: 'oncall@signoz.io',
html: '<p>a</p>',
headers: { 'X-Team': 'ops' },
sendResolved: true,
},
pagerduty: {
routingKey: 'k',
client: 'c',
clientUrl: 'https://c',
description: 'd',
severity: 'critical',
component: 'comp',
group: 'grp',
class: 'cls',
url: 'https://events.pagerduty.com',
details: { firing: '{{ .Alerts.Firing }}' },
sendResolved: true,
},
opsgenie: {
apiKey: 'k',
apiUrl: 'https://api.opsgenie.com',
message: 'm',
description: 'd',
source: 's',
priority: 'P1',
details: { env: 'prod' },
sendResolved: true,
},
msteams: {
webhookUrl: 'https://teams',
title: 't',
text: 'b',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/A',
title: 't',
text: 'b',
sendResolved: true,
},
jira: {
site: 'https://acme.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'a@b.c',
apiToken: 'tok',
summary: 's',
description: 'd',
priority: 'High',
labels: ['one', 'two'],
resolveTransition: 'Done',
reopenTransition: 'Reopen',
wontFixResolution: 'WontFix',
// the API only ever returns the canonical spelling
reopenDuration: '3d',
customFields: { customfield_1: 'v' },
sendResolved: true,
},
jsmops: {
apiKey: 'k',
message: 'm',
description: 'd',
priority: 'P2',
tags: 'prod,db',
sendResolved: true,
},
telegram: {
botToken: 'tok',
chatId: -1001234567890,
apiUrl: 'https://telegram.example.com',
title: '<b>{{ .CommonLabels.alertname }}</b>',
message: '<b>{{ .CommonLabels.alertname }}</b>',
messageThreadId: 42,
disableNotifications: true,
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/abc',
token: 'tok',
title: 't',
description: 'd',
metadata: { team: 'ops' },
sendResolved: true,
},
};
describe('load then save round trip', () => {
it.each(Object.keys(SPECS))('keeps every %s field', (kind) => {
const channel = {
id: '1',
name: `${kind}-channel`,
displayName: `${kind} channel`,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
config: { kind, spec: SPECS[kind] },
} as unknown as AlertmanagertypesGettableNotificationChannelDTO;
const { kind: loadedKind, values } = toChannelFormState(channel);
const saved = toUpdatableChannel(loadedKind, values);
expect(loadedKind).toBe(kind as ChannelKind);
expect(saved.config).toStrictEqual(channel.config);
});
it('never sends the display name inside the spec', () => {
const config = toChannelConfig(ChannelKind.slack, {
name: 'prod alerts',
apiUrl: 'https://hooks.slack.com/x',
});
expect(config.spec).not.toHaveProperty('name');
});
});

View File

@@ -0,0 +1,194 @@
export interface Channel {
send_resolved?: boolean;
name: string;
filter?: Partial<Array<LabelFilterStatement>>;
}
export interface SlackChannel extends Channel {
api_url?: string;
channel?: string;
title?: string;
text?: string;
}
export interface WebhookChannel extends Channel {
api_url?: string;
// basic auth
username?: string;
password?: string;
}
// PagerChannel configures alert manager to send
// events to pagerduty
export interface PagerChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#pagerduty_config
routing_key?: string;
// displays source of the event in pager duty
client?: string;
client_url?: string;
// A description of the incident
description?: string;
// Severity of the incident
severity?: string;
// The part or component of the affected system that is broken
component?: string;
// A cluster or grouping of sources
group?: string;
// The class/type of the event.
class?: string;
details?: string;
detailsArray?: Record<string, string>;
}
// OpsgenieChannel configures alert manager to send
// events to opsgenie
export interface OpsgenieChannel extends Channel {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#opsgenie_config
api_key: string;
message?: string;
// A description of the incident
description?: string;
// A backlink to the sender of the notification.
source?: string;
// A set of arbitrary key/value pairs that provide further detail
// about the alert.
details?: string;
detailsArray?: Record<string, string>;
// Priority level of alert. Possible values are P1, P2, P3, P4, and P5.
priority?: string;
}
export interface EmailChannel extends Channel {
// comma separated list of email addresses to send alerts to
to: string;
// HTML body of the email notification.
html: string;
// Further headers email header key/value pairs.
// [ headers: { <string>: <tmpl_string>, ... } ]
headers: Record<string, string>;
}
export const ValidatePagerChannel = (p: PagerChannel): string => {
if (!p) {
return 'Received unexpected input for this channel, please contact your administrator ';
}
if (!p.name || p.name === '') {
return 'Name is mandatory for creating a channel';
}
if (!p.routing_key || p.routing_key === '') {
return 'Routing Key is mandatory for creating pagerduty channel';
}
// validate details json
try {
JSON.parse(p.details || '{}');
} catch (e) {
return 'failed to parse additional information, please enter a valid json';
}
return '';
};
export enum ChannelType {
Slack = 'slack',
Email = 'email',
Webhook = 'webhook',
Pagerduty = 'pagerduty',
Opsgenie = 'opsgenie',
MsTeams = 'msteams',
GoogleChat = 'googlechat',
Jira = 'jira',
JsmOps = 'jsmops',
IncidentIO = 'incidentio',
}
// LabelFilterStatement will be used for preparing filter conditions / matchers
export interface LabelFilterStatement {
// ref: https://prometheus.io/docs/alerting/latest/configuration/#matcher
// label name
name: string;
// comparators supported by promql are =, !=, =~, or !~. =
comparator: string;
// filter value
value: string;
}
export interface MsTeamsChannel extends Channel {
webhook_url?: string;
title?: string;
text?: string;
}
export interface GoogleChatChannel extends Channel {
// incoming webhook url of the google chat space, must be an
// https url on chat.googleapis.com
webhook_url?: string;
title?: string;
text?: string;
}
// JiraChannel configures the Jira Cloud alert channel. Auth is basic auth
// (Atlassian account email + API token) carried in username / password.
export interface JiraChannel extends Channel {
// Jira Cloud base URL, e.g. https://acme.atlassian.net
site: string;
project: string;
issue_type: string;
// issue title template
summary?: string;
// issue body template, rendered to rich text server-side
description?: string;
// basic auth: username is the Atlassian account email, password is the API token
username: string;
password: string;
priority?: string;
labels?: string[];
resolve_transition?: string;
reopen_transition?: string;
// issues resolved with this resolution are never reopened; a refire
// creates a new issue instead
wont_fix_resolution?: string;
// duration string, e.g. 72h or 3d
reopen_duration?: string;
}
// IncidentIOChannel configures the incident.io alert channel, backed by an
// incident.io HTTP alert source (Alert Events V2 API).
export interface IncidentIOChannel extends Channel {
// per-source alert events URL, e.g.
// https://api.incident.io/v2/alert_events/http/<source_config_id>
url: string;
// the alert source's secret token
token: string;
// alert title template
title?: string;
// alert body template (markdown, rendered natively by incident.io)
description?: string;
// extra metadata pairs merged over the alert's labels (channel wins on clash)
metadata?: Record<string, string>;
}
// JsmOpsChannel configures the Jira Service Management Ops alert channel
// (ex-Opsgenie alert API). Auth is the JSM integration API key.
export interface JsmOpsChannel extends Channel {
api_key: string;
// alert title template
message?: string;
// alert body template (markdown, rendered to HTML server-side)
description?: string;
// priority template, resolves to P1-P5
priority?: string;
// tags, joined to a comma-separated string for the backend
tags?: string[];
}

View File

@@ -1,21 +1,20 @@
import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelTelegramConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelKind, ChannelSpecFormValues } from './types';
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from './config';
// shared by slack and ms teams, both render the same title / description boxes
export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO> =
{
text: `{{ range .Alerts -}}
export const SlackInitialConfig: Partial<SlackChannel> = {
text: `{{ range .Alerts -}}
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
*Summary:* {{ .Annotations.summary }}
@@ -27,7 +26,7 @@ export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO>
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
{{ end }}
{{ end }}`,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
@@ -38,29 +37,27 @@ export const SlackInitialConfig: Partial<AlertmanagertypesChannelSlackConfigDTO>
{{- end -}}
)
{{- end }}`,
};
};
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
// which the backend applies when title / text are left empty
export const GoogleChatInitialConfig: Partial<AlertmanagertypesChannelGoogleChatConfigDTO> =
{
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
text: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}{{ end }}
{{ end }}`,
};
};
// mirrors DefaultJiraSummaryTemplate / DefaultJiraDescriptionTemplate in
// pkg/types/alertmanagertypes/jira.go, which the backend applies when the
// summary / description are left empty. The description is markdown here and is
// wrapped in the ADF status panel + deep-links server-side.
export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
{
issueType: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const JiraInitialConfig: Partial<JiraChannel> = {
issue_type: 'Task',
summary: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}
@@ -68,11 +65,10 @@ export const JiraInitialConfig: Partial<AlertmanagertypesChannelJiraConfigDTO> =
**Description:** {{ .Annotations.description }}
{{ end }}
{{ end }}`,
};
};
export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfigDTO> =
{
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
export const PagerInitialConfig: Partial<PagerChannel> = {
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
{{" "}}(
{{- with .CommonLabels.Remove .GroupLabels.Names }}
@@ -83,21 +79,20 @@ export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfig
{{- end -}}
)
{{- end }}`,
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
clientUrl: 'https://enter-signoz-host-n-port-here/alerts',
details: {
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}',
},
};
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
client_url: 'https://enter-signoz-host-n-port-here/alerts',
details: JSON.stringify({
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}',
}),
};
export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConfigDTO> =
{
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
export const OpsgenieInitialConfig: Partial<OpsgenieChannel> = {
message: '{{ .CommonLabels.alertname }}',
description: `{{ if gt (len .Alerts.Firing) 0 -}}
Alerts Firing:
{{ range .Alerts.Firing }}
- Message: {{ .Annotations.description }}
@@ -119,20 +114,19 @@ export const OpsgenieInitialConfig: Partial<AlertmanagertypesChannelOpsgenieConf
{{ end }} Source: {{ .GeneratorURL }}
{{ end }}
{{- end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
};
// mirrors DefaultJSMOpsMessageTemplate / DefaultJSMOpsDescriptionTemplate in
// pkg/types/alertmanagertypes/jsmops.go, applied by the backend when message /
// description are left empty. send_resolved is seeded on so JSM alerts close on
// resolve (the backend cannot default it, see jsmops.go). priority mirrors the
// Opsgenie template mapping severity to P1-P5.
export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDTO> =
{
sendResolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const JsmOpsInitialConfig: Partial<JsmOpsChannel> = {
send_resolved: true,
message: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
@@ -146,20 +140,19 @@ export const JsmOpsInitialConfig: Partial<AlertmanagertypesChannelJSMOpsConfigDT
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: 'signoz-alert',
};
priority:
'{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
tags: ['signoz-alert'],
};
// mirrors DefaultIncidentIOTitleTemplate / DefaultIncidentIODescriptionTemplate
// in pkg/types/alertmanagertypes/incidentio.go, applied by the backend when
// title / description are left empty. send_resolved is seeded on so incident.io
// alerts resolve with the rule (the backend cannot default it).
export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIOConfigDTO> =
{
sendResolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
export const IncidentIOInitialConfig: Partial<IncidentIOChannel> = {
send_resolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
description: `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
@@ -173,12 +166,11 @@ export const IncidentIOInitialConfig: Partial<AlertmanagertypesChannelIncidentIO
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`,
};
};
export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO> =
{
sendResolved: true,
html: `<!--
export const EmailInitialConfig: Partial<EmailChannel> = {
send_resolved: true,
html: `<!--
Credits: https://github.com/mailgun/transactional-email-templates
-->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
@@ -570,40 +562,33 @@ export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO>
</table>
</body>
</html>`,
};
// mirrors DefaultTelegramTitleTemplate / DefaultTelegramMessageTemplate in
// pkg/types/alertmanagertypes/channel_telegram.go, so the form shows the
// templates it will send rather than two empty boxes. send_resolved mirrors the
// default upstream applies, so the switch shows what will be stored.
export const TelegramInitialConfig: Partial<AlertmanagertypesChannelTelegramConfigDTO> =
{
sendResolved: true,
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname | html }}`,
message: `{{ range .Alerts -}}
<b>Alert:</b> {{ .Labels.alertname | html }}{{ if .Labels.severity }} ({{ .Labels.severity | html }}){{ end }}
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary | html }}
{{ end }}{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description | html }}
{{ end }}{{ if .GeneratorURL }}<a href="{{ .GeneratorURL | html }}">View in SigNoz</a>
{{ end }}{{ if .Annotations.related_logs }}<a href="{{ .Annotations.related_logs | html }}">View related logs</a>
{{ end }}{{ if .Annotations.related_traces }}<a href="{{ .Annotations.related_traces | html }}">View related traces</a>
{{ end }}
{{ end }}`,
};
};
// prefilled values of every channel type, keyed by type so the form can apply
// exactly one set of defaults and swap it when the type changes
export const ChannelInitialConfig: Record<ChannelKind, ChannelSpecFormValues> =
{
[ChannelKind.slack]: SlackInitialConfig,
[ChannelKind.msteams]: SlackInitialConfig,
[ChannelKind.googlechat]: GoogleChatInitialConfig,
[ChannelKind.jira]: JiraInitialConfig,
[ChannelKind.jsmops]: JsmOpsInitialConfig,
[ChannelKind.incidentio]: IncidentIOInitialConfig,
[ChannelKind.pagerduty]: PagerInitialConfig,
[ChannelKind.opsgenie]: OpsgenieInitialConfig,
[ChannelKind.email]: EmailInitialConfig,
[ChannelKind.webhook]: {},
[ChannelKind.telegram]: TelegramInitialConfig,
};
export const ChannelInitialConfig: Record<
ChannelType,
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
> = {
[ChannelType.Slack]: SlackInitialConfig,
[ChannelType.MsTeams]: SlackInitialConfig,
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
[ChannelType.Jira]: JiraInitialConfig,
[ChannelType.JsmOps]: JsmOpsInitialConfig,
[ChannelType.IncidentIO]: IncidentIOInitialConfig,
[ChannelType.Pagerduty]: PagerInitialConfig,
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
[ChannelType.Email]: EmailInitialConfig,
[ChannelType.Webhook]: {},
};

View File

@@ -0,0 +1,818 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import createEmail from 'api/channels/createEmail';
import createMsTeamsApi from 'api/channels/createMsTeams';
import createOpsgenie from 'api/channels/createOpsgenie';
import createPagerApi from 'api/channels/createPager';
import createSlackApi from 'api/channels/createSlack';
import createWebhookApi from 'api/channels/createWebhook';
import testEmail from 'api/channels/testEmail';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsGenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useCreateChannel,
useTestChannel,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
ValidatePagerChannel,
WebhookChannel,
} from './config';
import { ChannelInitialConfig } from './defaults';
import {
isChannelType,
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareIncidentIORequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from './utils';
import './CreateAlertChannels.styles.scss';
function CreateAlertChannels({
preType = ChannelType.Slack,
}: CreateAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const { showErrorModal } = useErrorModal();
const [formInstance] = Form.useForm();
useEffect(() => {
logEvent('Alert Channel: Create channel page visited', {});
}, []);
const [selectedConfig, setSelectedConfig] = useState<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>(() => ({
send_resolved: true,
...ChannelInitialConfig[preType],
}));
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: createChannel } = useCreateChannel();
const { mutateAsync: testChannel } = useTestChannel();
const [type, setType] = useState<ChannelType>(preType);
const onTypeChangeHandler = useCallback(
(value: string) => {
const nextType = value as ChannelType;
if (nextType === type) {
return;
}
setType(nextType);
// the fields the types share (title, text, description) keep the value of
// the type that was selected before, so the new type's defaults have to be
// written to both the config and the form
const defaults = ChannelInitialConfig[nextType];
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
formInstance.setFieldsValue(defaults);
},
[type, formInstance],
);
const prepareSlackRequest = useCallback(
() => ({
api_url: selectedConfig?.api_url || '',
channel: selectedConfig?.channel || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onSlackHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createSlackApi(prepareSlackRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [selectedConfig, notifications, t, prepareSlackRequest, showErrorModal]);
const prepareWebhookRequest = useCallback(() => {
// initial api request without auth params
let request: WebhookChannel = {
api_url: selectedConfig?.api_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
};
if (selectedConfig?.username !== '' || selectedConfig?.password !== '') {
if (selectedConfig?.username !== '') {
// if username is not null then password must be passed
if (selectedConfig?.password !== '') {
request = {
...request,
username: selectedConfig.username,
password: selectedConfig.password,
};
} else {
notifications.error({
message: 'Error',
description: t('username_no_password'),
});
}
} else if (selectedConfig?.password !== '') {
// only password entered, set bearer token
request = {
...request,
username: '',
password: selectedConfig.password,
};
}
}
return request;
}, [notifications, t, selectedConfig]);
const onWebhookHandler = useCallback(async () => {
if (!selectedConfig.api_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareWebhookRequest();
await createWebhookApi(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.api_url,
notifications,
t,
prepareWebhookRequest,
showErrorModal,
]);
const preparePagerRequest = useCallback(() => {
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return null;
}
return {
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
routing_key: selectedConfig?.routing_key || '',
client: selectedConfig?.client || '',
client_url: selectedConfig?.client_url || '',
description: selectedConfig?.description || '',
severity: selectedConfig?.severity || '',
component: selectedConfig?.component || '',
group: selectedConfig?.group || '',
class: selectedConfig?.class || '',
details: selectedConfig.details || '',
detailsArray: JSON.parse(selectedConfig.details || '{}'),
};
}, [selectedConfig, notifications]);
const onPagerHandler = useCallback(async () => {
setSavingState(true);
const request = preparePagerRequest();
try {
if (request) {
await createPagerApi(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
}
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [preparePagerRequest, t, notifications, showErrorModal]);
const prepareOpsgenieRequest = useCallback(
() => ({
api_key: selectedConfig?.api_key || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
description: selectedConfig?.description || '',
message: selectedConfig?.message || '',
priority: selectedConfig?.priority || '',
}),
[selectedConfig],
);
const onOpsgenieHandler = useCallback(async () => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return;
}
setSavingState(true);
try {
await createOpsgenie(prepareOpsgenieRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.api_key,
notifications,
t,
prepareOpsgenieRequest,
showErrorModal,
]);
const prepareEmailRequest = useCallback(
() => ({
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
to: selectedConfig?.to || '',
html: selectedConfig?.html || '',
headers: selectedConfig?.headers || {},
}),
[selectedConfig],
);
const onEmailHandler = useCallback(async () => {
if (!selectedConfig.to) {
notifications.error({
message: 'Error',
description: t('to_required'),
});
return;
}
setSavingState(true);
try {
const request = prepareEmailRequest();
await createEmail(request);
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, notifications, t, showErrorModal, selectedConfig.to]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
}),
[selectedConfig],
);
const onMsTeamsHandler = useCallback(async () => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return;
}
setSavingState(true);
try {
await createMsTeamsApi(prepareMsTeamsRequest());
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(error as APIError);
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
selectedConfig.webhook_url,
notifications,
t,
prepareMsTeamsRequest,
showErrorModal,
]);
const validateGoogleChatConfig = useCallback((): boolean => {
if (!selectedConfig.webhook_url) {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
return false;
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
notifications.error({
message: 'Error',
description: t('google_chat_webhook_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.webhook_url, notifications, t]);
const onGoogleChatHandler = useCallback(async () => {
if (!validateGoogleChatConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJiraConfig = useCallback((): boolean => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
notifications.error({
message: 'Error',
description: t('jira_required_fields'),
});
return false;
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
notifications.error({
message: 'Error',
description: t('jira_site_invalid'),
});
return false;
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
notifications.error({
message: 'Error',
description: t('jira_reopen_duration_invalid'),
});
return false;
}
return true;
}, [selectedConfig, notifications, t]);
const onJiraHandler = useCallback(async () => {
if (!validateJiraConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJiraRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateJsmOpsConfig = useCallback((): boolean => {
if (!selectedConfig.api_key) {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
return false;
}
return true;
}, [selectedConfig.api_key, notifications, t]);
const onJsmOpsHandler = useCallback(async () => {
if (!validateJsmOpsConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareJsmOpsRequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const validateIncidentIOConfig = useCallback((): boolean => {
if (!selectedConfig.url || !selectedConfig.token) {
notifications.error({
message: 'Error',
description: t('incidentio_required_fields'),
});
return false;
}
if (!isValidIncidentIOURL(selectedConfig.url)) {
notifications.error({
message: 'Error',
description: t('incidentio_url_invalid'),
});
return false;
}
return true;
}, [selectedConfig.url, selectedConfig.token, notifications, t]);
const onIncidentIOHandler = useCallback(async () => {
if (!validateIncidentIOConfig()) {
return { status: 'failed', statusMessage: t('channel_creation_failed') };
}
setSavingState(true);
try {
await createChannel({ data: prepareIncidentIORequest(selectedConfig) });
notifications.success({
message: 'Success',
description: t('channel_creation_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_creation_done') };
} catch (error) {
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
return { status: 'failed', statusMessage: t('channel_creation_failed') };
} finally {
setSavingState(false);
}
}, [
validateIncidentIOConfig,
createChannel,
selectedConfig,
notifications,
t,
showErrorModal,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
if (!selectedConfig.name) {
notifications.error({
message: 'Error',
description: t('channel_name_required'),
});
return;
}
const functionMapper = {
[ChannelType.Slack]: onSlackHandler,
[ChannelType.Webhook]: onWebhookHandler,
[ChannelType.Pagerduty]: onPagerHandler,
[ChannelType.Opsgenie]: onOpsgenieHandler,
[ChannelType.MsTeams]: onMsTeamsHandler,
[ChannelType.Email]: onEmailHandler,
[ChannelType.GoogleChat]: onGoogleChatHandler,
[ChannelType.Jira]: onJiraHandler,
[ChannelType.JsmOps]: onJsmOpsHandler,
[ChannelType.IncidentIO]: onIncidentIOHandler,
};
if (isChannelType(value)) {
const functionToCall = functionMapper[value as keyof typeof functionMapper];
if (functionToCall) {
const result = await functionToCall();
logEvent('Alert Channel: Save channel', {
type: value,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: result?.status,
statusMessage: result?.statusMessage,
});
} else {
notifications.error({
message: 'Error',
description: t('selected_channel_invalid'),
});
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
onSlackHandler,
onWebhookHandler,
onPagerHandler,
onOpsgenieHandler,
onMsTeamsHandler,
onEmailHandler,
onGoogleChatHandler,
onJiraHandler,
onJsmOpsHandler,
onIncidentIOHandler,
notifications,
t,
],
);
const performChannelTest = useCallback(
async (channelType: ChannelType) => {
setTestingState(true);
try {
let request;
switch (channelType) {
case ChannelType.Webhook:
request = prepareWebhookRequest();
await testWebhookApi(request);
break;
case ChannelType.Slack:
request = prepareSlackRequest();
await testSlackApi(request);
break;
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) {
await testPagerApi(request);
}
break;
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
await testMsTeamsApi(request);
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
await testOpsGenie(request);
break;
case ChannelType.Email:
request = prepareEmailRequest();
await testEmail(request);
break;
case ChannelType.GoogleChat:
if (!validateGoogleChatConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
case ChannelType.Jira:
if (!validateJiraConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
case ChannelType.JsmOps:
if (!validateJsmOpsConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
case ChannelType.IncidentIO:
if (!validateIncidentIOConfig()) {
setTestingState(false);
return;
}
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
break;
default:
notifications.error({
message: 'Error',
description: t('test_unsupported'),
});
setTestingState(false);
return;
}
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: 'Test success',
});
} catch (error) {
showErrorModal(
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'true',
status: 'Test failed',
});
}
setTestingState(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
prepareWebhookRequest,
t,
preparePagerRequest,
prepareOpsgenieRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareEmailRequest,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
validateIncidentIOConfig,
testChannel,
notifications,
],
);
const onTestHandler = useCallback(
async (value: ChannelType) => {
performChannelTest(value);
},
[performChannelTest],
);
return (
<div className="create-alert-channels-container">
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
savingState,
testingState,
title: t('page_title_create'),
initialValue: {
type,
...selectedConfig,
},
}}
/>
</div>
);
}
interface CreateAlertChannelsProps {
preType: ChannelType;
}
export default CreateAlertChannels;

View File

@@ -1,45 +0,0 @@
import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelTelegramConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
/** The API's own vocabulary for a channel's kind. */
export const ChannelKind = AlertmanagertypesChannelKindDTO;
export type ChannelKind = AlertmanagertypesChannelKindDTO;
/**
* Every kind's spec merged into one object, so switching kind mid-form keeps the
* fields the kinds share (title, text, description). Each field is typed by the
* generated schema, so what is optional here is what the API models as optional.
*/
export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelSlackConfigDTO &
AlertmanagertypesChannelWebhookConfigDTO &
AlertmanagertypesChannelEmailConfigDTO &
AlertmanagertypesChannelPagerdutyConfigDTO &
AlertmanagertypesChannelOpsgenieConfigDTO &
AlertmanagertypesChannelMSTeamsConfigDTO &
AlertmanagertypesChannelGoogleChatConfigDTO &
AlertmanagertypesChannelJiraConfigDTO &
AlertmanagertypesChannelJSMOpsConfigDTO &
AlertmanagertypesChannelIncidentIOConfigDTO &
AlertmanagertypesChannelTelegramConfigDTO
>;
/**
* The form edits a spec plus the channel's display name, which the API carries
* outside the config.
*/
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
};

View File

@@ -1,3 +1,23 @@
import {
AlertmanagertypesIncidentIOReceiverConfigDTO,
AlertmanagertypesJiraReceiverConfigDTO,
AlertmanagertypesJSMOpsReceiverConfigDTO,
AlertmanagertypesPostableChannelDTO,
ConfigSecretURLDTO,
ModelDurationDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
ChannelType,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
} from './config';
export const isChannelType = (type: string): type is ChannelType =>
Object.values(ChannelType).includes(type as ChannelType);
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
// the backend enforces the same two rules, this is only for a nicer error experience
@@ -12,6 +32,22 @@ export const isValidGoogleChatWebhookURL = (url: string): boolean => {
}
};
// create, update and test all send the same body shape
export const prepareGoogleChatRequest = (
config: Partial<GoogleChatChannel>,
): AlertmanagertypesPostableChannelDTO => ({
name: config.name || '',
googlechat_configs: [
{
// the generated type models go's config.SecretURL as an object, the api takes a string
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
title: config.title || '',
text: config.text || '',
send_resolved: config.send_resolved || false,
},
],
});
const JIRA_CLOUD_HOST_SUFFIX = '.atlassian.net';
// the backend enforces the same rule, this is only for a nicer error experience
@@ -57,51 +93,85 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
return totalMs >= JIRA_MIN_REOPEN_MS;
};
/**
* How prometheus `model.Duration` prints itself, which is the only spelling the
* API accepts back: years and weeks only when the remainder divides exactly,
* then days, hours, minutes, seconds and milliseconds greedily. "72h" prints as
* "3d", "7d" as "1w".
*/
const JIRA_DURATION_FORMAT: { unit: string; ms: number; exact: boolean }[] = [
{ unit: 'y', ms: JIRA_DURATION_UNIT_MS.y, exact: true },
{ unit: 'w', ms: JIRA_DURATION_UNIT_MS.w, exact: true },
{ unit: 'd', ms: JIRA_DURATION_UNIT_MS.d, exact: false },
{ unit: 'h', ms: JIRA_DURATION_UNIT_MS.h, exact: false },
{ unit: 'm', ms: JIRA_DURATION_UNIT_MS.m, exact: false },
{ unit: 's', ms: JIRA_DURATION_UNIT_MS.s, exact: false },
{ unit: 'ms', ms: JIRA_DURATION_UNIT_MS.ms, exact: false },
];
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJiraRequest = (
config: Partial<JiraChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jira: AlertmanagertypesJiraReceiverConfigDTO = {
site: config.site || '',
project: config.project || '',
issue_type: config.issue_type || '',
send_resolved: config.send_resolved || false,
http_config: {
basic_auth: {
username: config.username || '',
password: config.password || '',
},
},
};
/**
* The API rejects a duration it would print differently, so the user's spelling
* is normalised before it is sent. A value it cannot parse is left alone for the
* validator to reject.
*/
export const canonicalJiraReopenDuration = (value: string): string => {
if (!value || value === '0' || !JIRA_DURATION_RE.test(value)) {
return value;
if (config.summary) {
jira.summary = config.summary;
}
if (config.description) {
jira.description = config.description;
}
if (config.priority) {
jira.priority = config.priority;
}
if (config.labels?.length) {
jira.labels = config.labels;
}
if (config.resolve_transition) {
jira.resolve_transition = config.resolve_transition;
}
if (config.reopen_transition) {
jira.reopen_transition = config.reopen_transition;
}
if (config.wont_fix_resolution) {
jira.wont_fix_resolution = config.wont_fix_resolution;
}
if (config.reopen_duration) {
// the generated type models go's model.Duration as a number, the api takes a
// duration string like "72h"
jira.reopen_duration = config.reopen_duration as unknown as ModelDurationDTO;
}
let remaining = 0;
for (const [, amount, unit] of value.matchAll(JIRA_DURATION_TOKEN_RE)) {
remaining += Number(amount) * JIRA_DURATION_UNIT_MS[unit];
return {
name: config.name || '',
jira_configs: [jira],
};
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareJsmOpsRequest = (
config: Partial<JsmOpsChannel>,
): AlertmanagertypesPostableChannelDTO => {
const jsmops: AlertmanagertypesJSMOpsReceiverConfigDTO = {
api_key: config.api_key || '',
send_resolved: config.send_resolved || false,
};
if (config.message) {
jsmops.message = config.message;
}
if (remaining === 0) {
return '0s';
if (config.description) {
jsmops.description = config.description;
}
if (config.priority) {
jsmops.priority = config.priority;
}
if (config.tags?.length) {
// the backend takes a comma-separated string and splits it back
jsmops.tags = config.tags.join(',');
}
return JIRA_DURATION_FORMAT.reduce((canonical, { unit, ms, exact }) => {
if (exact && remaining % ms !== 0) {
return canonical;
}
const count = Math.floor(remaining / ms);
if (count === 0) {
return canonical;
}
remaining -= count * ms;
return `${canonical}${count}${unit}`;
}, '');
return {
name: config.name || '',
jsmops_configs: [jsmops],
};
};
const INCIDENTIO_EVENTS_PATH_PREFIX = '/v2/alert_events/http/';
@@ -120,3 +190,33 @@ export const isValidIncidentIOURL = (url: string): boolean => {
return false;
}
};
// create, update and test all send the same body shape. Optional fields are
// omitted when empty so the backend applies its defaults.
export const prepareIncidentIORequest = (
config: Partial<IncidentIOChannel>,
): AlertmanagertypesPostableChannelDTO => {
const incidentio: AlertmanagertypesIncidentIOReceiverConfigDTO = {
url: config.url || '',
token: config.token || '',
send_resolved: config.send_resolved || false,
};
if (config.title) {
incidentio.title = config.title;
}
if (config.description) {
incidentio.description = config.description;
}
const metadata = Object.fromEntries(
Object.entries(config.metadata || {}).filter(([key]) => key.trim() !== ''),
);
if (Object.keys(metadata).length > 0) {
incidentio.metadata = metadata;
}
return {
name: config.name || '',
incidentio_configs: [incidentio],
};
};

View File

@@ -1,110 +0,0 @@
import { TFunction } from 'i18next';
import { ChannelFormValues, ChannelKind } from './types';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from './utils';
type Validator = (values: ChannelFormValues, t: TFunction) => string | null;
const requireWebhookUrl: Validator = (values, t) =>
values.webhookUrl ? null : t('webhook_url_required');
const requireApiKey: Validator = (values, t) =>
values.apiKey ? null : t('api_key_required');
const validateSlack: Validator = (values, t) =>
values.apiUrl ? null : t('webhook_url_required');
const validateWebhook: Validator = (values, t) => {
if (!values.url) {
return t('webhook_url_required');
}
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
return values.username && !values.password ? t('username_no_password') : null;
};
const validatePagerduty: Validator = (values, t) =>
values.routingKey ? null : t('routing_key_required');
const validateEmail: Validator = (values, t) =>
values.to ? null : t('to_required');
const validateGoogleChat: Validator = (values, t) => {
if (!values.webhookUrl) {
return t('webhook_url_required');
}
return isValidGoogleChatWebhookURL(values.webhookUrl)
? null
: t('google_chat_webhook_url_invalid');
};
const validateJira: Validator = (values, t) => {
if (
!values.site ||
!values.email ||
!values.apiToken ||
!values.project ||
!values.issueType
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(values.site)) {
return t('jira_site_invalid');
}
if (
values.reopenDuration &&
!isValidJiraReopenDuration(values.reopenDuration)
) {
return t('jira_reopen_duration_invalid');
}
return null;
};
const validateIncidentIO: Validator = (values, t) => {
if (!values.url || !values.token) {
return t('incidentio_required_fields');
}
return isValidIncidentIOURL(values.url) ? null : t('incidentio_url_invalid');
};
// a chat id of 0 is as absent as an empty one, and the API says so too
const validateTelegram: Validator = (values, t) =>
values.botToken && values.chatId ? null : t('telegram_required_fields');
const VALIDATORS: Record<ChannelKind, Validator> = {
[ChannelKind.slack]: validateSlack,
[ChannelKind.webhook]: validateWebhook,
[ChannelKind.pagerduty]: validatePagerduty,
[ChannelKind.opsgenie]: requireApiKey,
[ChannelKind.jsmops]: requireApiKey,
[ChannelKind.email]: validateEmail,
[ChannelKind.msteams]: requireWebhookUrl,
[ChannelKind.googlechat]: validateGoogleChat,
[ChannelKind.jira]: validateJira,
[ChannelKind.incidentio]: validateIncidentIO,
[ChannelKind.telegram]: validateTelegram,
};
/**
* Client-side validation for the fields the API rejects outright, so a save
* round trip is not spent on an obviously incomplete form. Each rule mirrors the
* matching `Validate()` in `pkg/types/alertmanagertypes`. Returns the message to
* show, or null when the form can be submitted.
*/
export function validateChannel(
kind: ChannelKind,
values: ChannelFormValues,
t: TFunction,
): string | null {
if (!values.name) {
return t('channel_name_required');
}
const validate = VALIDATORS[kind];
return validate ? validate(values, t) : t('selected_channel_invalid');
}

View File

@@ -1,8 +1,12 @@
import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
@@ -17,11 +21,14 @@ function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const {
data: channels,
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useChannelOptions();
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||

View File

@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: ChannelOption[] = [];
const mockChannels: Channels[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
@@ -85,17 +85,16 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
// Mock getAllChannels API
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
jest.mock('api/channels/getAll', () => ({
__esModule: true,
useChannelOptions: jest.fn(() => ({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as ChannelOption[],
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
}));
// Mock alert format categories

View File

@@ -3,7 +3,7 @@ import type { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
@@ -57,7 +57,7 @@ const mockThreshold = {
color: '#ff0000',
};
const mockChannels: ChannelOption[] = [
const mockChannels: Channels[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,

View File

@@ -1,5 +1,5 @@
import type { DefaultOptionType } from 'antd/es/select';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import {
NotificationSettingsAction,
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: ChannelOption[];
channels: Channels[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
}
export interface AnomalyAndThresholdProps {
channels: ChannelOption[];
channels: Channels[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -0,0 +1,863 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import editEmail from 'api/channels/editEmail';
import editMsTeamsApi from 'api/channels/editMsTeams';
import editOpsgenie from 'api/channels/editOpsgenie';
import editPagerApi from 'api/channels/editPager';
import editSlackApi from 'api/channels/editSlack';
import editWebhookApi from 'api/channels/editWebhook';
import testEmail from 'api/channels/testEmail';
import testMsTeamsApi from 'api/channels/testMsTeams';
import testOpsgenie from 'api/channels/testOpsgenie';
import testPagerApi from 'api/channels/testPager';
import testSlackApi from 'api/channels/testSlack';
import testWebhookApi from 'api/channels/testWebhook';
import logEvent from 'api/common/logEvent';
import {
useTestChannel,
useUpdateChannelByID,
} from 'api/generated/services/channels';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import {
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
MsTeamsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
ValidatePagerChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import {
isValidGoogleChatWebhookURL,
isValidIncidentIOURL,
isValidJiraReopenDuration,
isValidJiraSiteURL,
prepareGoogleChatRequest,
prepareIncidentIORequest,
prepareJiraRequest,
prepareJsmOpsRequest,
} from 'container/CreateAlertChannels/utils';
import FormAlertChannels from 'container/FormAlertChannels';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
function EditAlertChannels({
initialValue,
channelId: id,
}: EditAlertChannelsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('channels');
const [formInstance] = Form.useForm();
const [selectedConfig, setSelectedConfig] = useState<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
MsTeamsChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>({
...initialValue,
});
const [savingState, setSavingState] = useState<boolean>(false);
const [testingState, setTestingState] = useState<boolean>(false);
const { notifications } = useNotifications();
const { mutateAsync: updateChannel } = useUpdateChannelByID();
const { mutateAsync: testChannel } = useTestChannel();
const notifyError = useCallback(
(error: unknown): APIError => {
const apiError =
error instanceof APIError
? error
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
notifications.error({
message: apiError.getErrorCode(),
description: apiError.getErrorMessage(),
});
return apiError;
},
[notifications],
);
const [type, setType] = useState<ChannelType>(
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
);
const onTypeChangeHandler = useCallback((value: string) => {
setType(value as ChannelType);
}, []);
useEffect(() => {
formInstance.setFieldsValue({
...initialValue,
});
}, [formInstance, initialValue]);
const prepareSlackRequest = useCallback(
() => ({
api_url: selectedConfig?.api_url || '',
channel: selectedConfig?.channel || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onSlackEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_url === '') {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
try {
await editSlackApi(prepareSlackRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareSlackRequest, t, notifications, selectedConfig]);
const prepareWebhookRequest = useCallback(() => {
const { name, username, password } = selectedConfig;
return {
api_url: selectedConfig?.api_url || '',
name: name || '',
send_resolved: selectedConfig?.send_resolved || false,
username,
password,
id,
};
}, [id, selectedConfig]);
const onWebhookEditHandler = useCallback(async () => {
setSavingState(true);
const { username, password } = selectedConfig;
const showError = (msg: string): void => {
notifications.error({
message: 'Error',
description: msg,
});
};
if (selectedConfig?.api_url === '') {
showError(t('webhook_url_required'));
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
if (username && (!password || password === '')) {
showError(t('username_no_password'));
setSavingState(false);
return { status: 'failed', statusMessage: t('username_no_password') };
}
try {
await editWebhookApi(prepareWebhookRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareWebhookRequest, t, notifications, selectedConfig]);
const prepareEmailRequest = useCallback(
() => ({
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
to: selectedConfig.to || '',
html: selectedConfig.html || '',
headers: selectedConfig.headers || {},
id,
}),
[id, selectedConfig],
);
const onEmailEditHandler = useCallback(async () => {
setSavingState(true);
const request = prepareEmailRequest();
try {
await editEmail(request);
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareEmailRequest, t, notifications]);
const preparePagerRequest = useCallback(
() => ({
name: selectedConfig.name || '',
send_resolved: selectedConfig?.send_resolved || false,
routing_key: selectedConfig.routing_key,
client: selectedConfig.client,
client_url: selectedConfig.client_url,
description: selectedConfig.description,
severity: selectedConfig.severity,
component: selectedConfig.component,
class: selectedConfig.class,
group: selectedConfig.group,
details: selectedConfig.details,
detailsArray: JSON.parse(selectedConfig.details || '{}'),
id,
}),
[id, selectedConfig],
);
const onPagerEditHandler = useCallback(async () => {
setSavingState(true);
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setSavingState(false);
return { status: 'failed', statusMessage: validationError };
}
try {
await editPagerApi(preparePagerRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [preparePagerRequest, notifications, selectedConfig, t]);
const prepareOpsgenieRequest = useCallback(
() => ({
name: selectedConfig.name || '',
send_resolved: selectedConfig?.send_resolved || false,
api_key: selectedConfig.api_key || '',
message: selectedConfig.message || '',
description: selectedConfig.description || '',
priority: selectedConfig.priority || '',
id,
}),
[id, selectedConfig],
);
const onOpsgenieEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.api_key === '') {
notifications.error({
message: 'Error',
description: t('api_key_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('api_key_required') };
}
try {
await editOpsgenie(prepareOpsgenieRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareOpsgenieRequest, t, notifications, selectedConfig]);
const prepareMsTeamsRequest = useCallback(
() => ({
webhook_url: selectedConfig?.webhook_url || '',
name: selectedConfig?.name || '',
send_resolved: selectedConfig?.send_resolved || false,
text: selectedConfig?.text || '',
title: selectedConfig?.title || '',
id,
}),
[id, selectedConfig],
);
const onMsTeamsEditHandler = useCallback(async () => {
setSavingState(true);
if (selectedConfig?.webhook_url === '') {
notifications.error({
message: 'Error',
description: t('webhook_url_required'),
});
setSavingState(false);
return { status: 'failed', statusMessage: t('webhook_url_required') };
}
try {
await editMsTeamsApi(prepareMsTeamsRequest());
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
return {
status: 'failed',
statusMessage:
(error as APIError).getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
const validateGoogleChatConfig = useCallback((): string => {
if (!selectedConfig?.webhook_url) {
return t('webhook_url_required');
}
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
return t('google_chat_webhook_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onGoogleChatEditHandler = useCallback(async () => {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareGoogleChatRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateGoogleChatConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJiraConfig = useCallback((): string => {
if (
!selectedConfig.site ||
!selectedConfig.username ||
!selectedConfig.password ||
!selectedConfig.project ||
!selectedConfig.issue_type
) {
return t('jira_required_fields');
}
if (!isValidJiraSiteURL(selectedConfig.site)) {
return t('jira_site_invalid');
}
if (
selectedConfig.reopen_duration &&
!isValidJiraReopenDuration(selectedConfig.reopen_duration)
) {
return t('jira_reopen_duration_invalid');
}
return '';
}, [selectedConfig, t]);
const onJiraEditHandler = useCallback(async () => {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJiraRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJiraConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateJsmOpsConfig = useCallback((): string => {
if (!selectedConfig.api_key) {
return t('api_key_required');
}
return '';
}, [selectedConfig, t]);
const onJsmOpsEditHandler = useCallback(async () => {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareJsmOpsRequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateJsmOpsConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const validateIncidentIOConfig = useCallback((): string => {
if (!selectedConfig.url || !selectedConfig.token) {
return t('incidentio_required_fields');
}
if (!isValidIncidentIOURL(selectedConfig.url)) {
return t('incidentio_url_invalid');
}
return '';
}, [selectedConfig, t]);
const onIncidentIOEditHandler = useCallback(async () => {
const validationError = validateIncidentIOConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
return { status: 'failed', statusMessage: validationError };
}
setSavingState(true);
try {
await updateChannel({
pathParams: { id },
data: prepareIncidentIORequest(selectedConfig),
});
notifications.success({
message: 'Success',
description: t('channel_edit_done'),
});
history.replace(ROUTES.ALL_CHANNELS);
return { status: 'success', statusMessage: t('channel_edit_done') };
} catch (error) {
const apiError = notifyError(error);
return {
status: 'failed',
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
};
} finally {
setSavingState(false);
}
}, [
validateIncidentIOConfig,
updateChannel,
id,
selectedConfig,
notifications,
notifyError,
t,
]);
const onSaveHandler = useCallback(
async (value: ChannelType) => {
let result;
if (value === ChannelType.Slack) {
result = await onSlackEditHandler();
} else if (value === ChannelType.Webhook) {
result = await onWebhookEditHandler();
} else if (value === ChannelType.Pagerduty) {
result = await onPagerEditHandler();
} else if (value === ChannelType.MsTeams) {
result = await onMsTeamsEditHandler();
} else if (value === ChannelType.Opsgenie) {
result = await onOpsgenieEditHandler();
} else if (value === ChannelType.Email) {
result = await onEmailEditHandler();
} else if (value === ChannelType.GoogleChat) {
result = await onGoogleChatEditHandler();
} else if (value === ChannelType.Jira) {
result = await onJiraEditHandler();
} else if (value === ChannelType.JsmOps) {
result = await onJsmOpsEditHandler();
} else if (value === ChannelType.IncidentIO) {
result = await onIncidentIOEditHandler();
}
logEvent('Alert Channel: Save channel', {
type: value,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: result?.status,
statusMessage: result?.statusMessage,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
onSlackEditHandler,
onWebhookEditHandler,
onPagerEditHandler,
onMsTeamsEditHandler,
onOpsgenieEditHandler,
onEmailEditHandler,
onGoogleChatEditHandler,
onJiraEditHandler,
onJsmOpsEditHandler,
onIncidentIOEditHandler,
],
);
const performChannelTest = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
async (channelType: ChannelType) => {
setTestingState(true);
try {
let request;
switch (channelType) {
case ChannelType.Webhook:
request = prepareWebhookRequest();
await testWebhookApi(request);
break;
case ChannelType.Slack:
request = prepareSlackRequest();
await testSlackApi(request);
break;
case ChannelType.Pagerduty:
request = preparePagerRequest();
if (request) {
await testPagerApi(request);
}
break;
case ChannelType.MsTeams:
request = prepareMsTeamsRequest();
if (request) {
await testMsTeamsApi(request);
}
break;
case ChannelType.Opsgenie:
request = prepareOpsgenieRequest();
if (request) {
await testOpsgenie(request);
}
break;
case ChannelType.Email:
request = prepareEmailRequest();
if (request) {
await testEmail(request);
}
break;
case ChannelType.GoogleChat: {
const validationError = validateGoogleChatConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
break;
}
case ChannelType.Jira: {
const validationError = validateJiraConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJiraRequest(selectedConfig) });
break;
}
case ChannelType.JsmOps: {
const validationError = validateJsmOpsConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareJsmOpsRequest(selectedConfig) });
break;
}
case ChannelType.IncidentIO: {
const validationError = validateIncidentIOConfig();
if (validationError !== '') {
notifications.error({
message: 'Error',
description: validationError,
});
setTestingState(false);
return;
}
await testChannel({ data: prepareIncidentIORequest(selectedConfig) });
break;
}
default:
notifications.error({
message: 'Error',
description: t('test_unsupported'),
});
setTestingState(false);
return;
}
notifications.success({
message: 'Success',
description: t('channel_test_done'),
});
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: 'Test success',
});
} catch (error) {
notifyError(error);
logEvent('Alert Channel: Test notification', {
type: channelType,
sendResolvedAlert: selectedConfig?.send_resolved,
name: selectedConfig?.name,
new: 'false',
status: 'Test failed',
});
}
setTestingState(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
t,
notifyError,
validateGoogleChatConfig,
validateJiraConfig,
validateJsmOpsConfig,
validateIncidentIOConfig,
testChannel,
prepareWebhookRequest,
preparePagerRequest,
prepareSlackRequest,
prepareMsTeamsRequest,
prepareOpsgenieRequest,
prepareEmailRequest,
notifications,
],
);
const onTestHandler = useCallback(
async (value: ChannelType) => {
performChannelTest(value);
},
[performChannelTest],
);
return (
<FormAlertChannels
{...{
formInstance,
onTypeChangeHandler,
setSelectedConfig,
type,
onTestHandler,
onSaveHandler,
testingState,
savingState,
title: t('page_title_edit'),
initialValue,
editing: true,
}}
/>
);
}
interface EditAlertChannelsProps {
initialValue: {
[x: string]: unknown;
};
channelId: string;
}
export default EditAlertChannels;

View File

@@ -2,7 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { EmailChannel } from '../../CreateAlertChannels/config';
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -43,7 +43,7 @@ function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
}
interface EmailFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
}
export default EmailForm;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
@@ -12,7 +12,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
return (
<>
<Form.Item
name="webhookUrl"
name="webhook_url"
label={t('field_webhook_url')}
required
rules={[
@@ -38,7 +38,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhookUrl: event.target.value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -76,7 +76,7 @@ function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
}
export default GoogleChat;

View File

@@ -4,7 +4,7 @@ import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
interface MetadataRow {
key: string;
@@ -23,7 +23,7 @@ function IncidentIOSettings({
})),
);
const update = (patch: Partial<ChannelSpecFormValues>): void =>
const update = (patch: Partial<IncidentIOChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const syncMetadata = (rows: MetadataRow[]): void => {
@@ -161,7 +161,7 @@ function IncidentIOSettings({
}
interface IncidentIOProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
initialMetadata?: Record<string, string>;
}

View File

@@ -4,7 +4,7 @@ import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { JiraChannel } from '../../CreateAlertChannels/config';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
@@ -13,7 +13,7 @@ import {
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<ChannelSpecFormValues>): void =>
const update = (patch: Partial<JiraChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
@@ -45,49 +45,49 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="resolveTransition"
name="resolve_transition"
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
>
<Input
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolveTransition: event.target.value })
update({ resolve_transition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</Form.Item>
<Form.Item
name="reopenTransition"
name="reopen_transition"
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
>
<Input
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopenTransition: event.target.value })
update({ reopen_transition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</Form.Item>
<Form.Item
name="wontFixResolution"
name="wont_fix_resolution"
label={t('field_jira_wont_fix_resolution')}
help={t('help_jira_wont_fix_resolution')}
>
<Input
placeholder={t('placeholder_jira_wont_fix_resolution')}
onChange={(event): void =>
update({ wontFixResolution: event.target.value })
update({ wont_fix_resolution: event.target.value })
}
data-testid="jira-wont-fix-resolution-textbox"
/>
</Form.Item>
<Form.Item
name="reopenDuration"
name="reopen_duration"
label={t('field_jira_reopen_duration')}
extra={t('help_jira_reopen_duration')}
rules={[
@@ -111,7 +111,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
>
<Input
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopenDuration: event.target.value })}
onChange={(event): void => update({ reopen_duration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</Form.Item>
@@ -167,26 +167,26 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="email"
name="username"
label={t('field_jira_email')}
help={t('help_jira_email')}
required
>
<Input
onChange={(event): void => update({ email: event.target.value })}
onChange={(event): void => update({ username: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<Form.Item
name="apiToken"
name="password"
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ apiToken: event.target.value })}
onChange={(event): void => update({ password: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
@@ -200,13 +200,13 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
</Form.Item>
<Form.Item
name="issueType"
name="issue_type"
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
>
<Input
onChange={(event): void => update({ issueType: event.target.value })}
onChange={(event): void => update({ issue_type: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
@@ -250,7 +250,7 @@ function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
}
export default JiraSettings;

View File

@@ -3,12 +3,12 @@ import { useTranslation } from 'react-i18next';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<ChannelSpecFormValues>): void =>
const update = (patch: Partial<JsmOpsChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
@@ -29,17 +29,12 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
name="tags"
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
// the API takes one comma-separated string, the control edits chips
getValueProps={(value: string | undefined): { value: string[] } => ({
value: value ? value.split(',') : [],
})}
normalize={(value: string[]): string => value.join(',')}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: (value as string[]).join(',') })}
onChange={(value): void => update({ tags: value as string[] })}
data-testid="jsmops-tags-select"
/>
</Form.Item>
@@ -65,14 +60,14 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
</Typography.Text>
<Form.Item
name="apiKey"
name="api_key"
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
>
<Input
type="password"
onChange={(event): void => update({ apiKey: event.target.value })}
onChange={(event): void => update({ api_key: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
@@ -116,7 +111,7 @@ function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
}
export default JsmOpsSettings;

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { MsTeamsChannel } from '../../CreateAlertChannels/config';
function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -11,7 +11,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
return (
<>
<Form.Item
name="webhookUrl"
name="webhook_url"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -28,7 +28,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhookUrl: event.target.value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -67,7 +67,7 @@ function MsTeams({ setSelectedConfig }: MsTeamsProps): JSX.Element {
interface MsTeamsProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<ChannelSpecFormValues>>
React.SetStateAction<Partial<MsTeamsChannel>>
>;
}

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { OpsgenieChannel } from '../../CreateAlertChannels/config';
const { TextArea } = Input;
@@ -21,7 +21,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
return (
<>
<Form.Item
name="apiKey"
name="api_key"
label={t('field_opsgenie_api_key')}
tooltip={{
title: (
@@ -36,7 +36,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
required
>
<Input
onChange={handleInputChange('apiKey')}
onChange={handleInputChange('api_key')}
data-testid="opsgenie-api-key-textbox"
/>
</Form.Item>
@@ -88,7 +88,7 @@ function OpsgenieForm({ setSelectedConfig }: OpsgenieFormProps): JSX.Element {
interface OpsgenieFormProps {
setSelectedConfig: React.Dispatch<
React.SetStateAction<Partial<ChannelSpecFormValues>>
React.SetStateAction<Partial<OpsgenieChannel>>
>;
}

View File

@@ -3,20 +3,16 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import PagerDetails from './PagerDetails';
import { PagerChannel } from '../../CreateAlertChannels/config';
const { TextArea } = Input;
function PagerForm({
setSelectedConfig,
initialDetails,
}: PagerFormProps): JSX.Element {
function PagerForm({ setSelectedConfig }: PagerFormProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="routingKey"
name="routing_key"
label={t('field_pager_routing_key')}
tooltip={{
title: (
@@ -33,7 +29,7 @@ function PagerForm({
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
routingKey: event.target.value,
routing_key: event.target.value,
}));
}}
data-testid="pager-routing-key-textbox"
@@ -75,10 +71,22 @@ function PagerForm({
/>
</Form.Item>
<PagerDetails
setSelectedConfig={setSelectedConfig}
initialDetails={initialDetails}
/>
<Form.Item
name="details"
help={t('help_pager_details')}
label={t('field_pager_details')}
>
<TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
details: event.target.value,
}))
}
data-testid="pager-additional-details-textarea"
/>
</Form.Item>
<Form.Item
name="component"
@@ -143,7 +151,7 @@ function PagerForm({
</Form.Item>
<Form.Item
name="clientUrl"
name="client_url"
help={t('help_pager_client_url')}
label={t('field_pager_client_url')}
>
@@ -151,7 +159,7 @@ function PagerForm({
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
clientUrl: event.target.value,
client_url: event.target.value,
}))
}
data-testid="pager-client-url-textarea"
@@ -162,8 +170,7 @@ function PagerForm({
}
interface PagerFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialDetails?: Record<string, string>;
setSelectedConfig: Dispatch<SetStateAction<Partial<PagerChannel>>>;
}
export default PagerForm;

View File

@@ -1,64 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
const { TextArea } = Input;
interface PagerDetailsProps {
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
initialDetails?: Record<string, string>;
}
const serialize = (details?: Record<string, string>): string =>
details && Object.keys(details).length > 0 ? JSON.stringify(details) : '';
/**
* The API models these details as a map, so the raw JSON the box edits is held
* here: a half-typed object is not valid JSON and would not survive a round trip
* through the form's parsed value.
*/
function PagerDetails({
setSelectedConfig,
initialDetails,
}: PagerDetailsProps): JSX.Element {
const { t } = useTranslation('channels');
const [text, setText] = useState<string>(() => serialize(initialDetails));
const [error, setError] = useState<string | null>(null);
const onChange = (value: string): void => {
setText(value);
if (value.trim() === '') {
setError(null);
setSelectedConfig((config) => ({ ...config, details: {} }));
return;
}
try {
const parsed = JSON.parse(value);
setError(null);
setSelectedConfig((config) => ({ ...config, details: parsed }));
} catch {
setError(t('pager_details_invalid_json'));
}
};
return (
<Form.Item
help={error ?? t('help_pager_details')}
label={t('field_pager_details')}
validateStatus={error ? 'error' : undefined}
>
<TextArea
rows={4}
value={text}
onChange={(event): void => onChange(event.target.value)}
data-testid="pager-additional-details-textarea"
/>
</Form.Item>
);
}
export default PagerDetails;

View File

@@ -3,28 +3,17 @@ import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import {
AlertmanagertypesChannelSlackActionDTO,
AlertmanagertypesChannelSlackFieldDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import SlackActions from './SlackActions';
import SlackFields from './SlackFields';
import { SlackChannel } from '../../CreateAlertChannels/config';
const { TextArea } = Input;
function Slack({
setSelectedConfig,
initialFields,
initialActions,
}: SlackProps): JSX.Element {
function Slack({ setSelectedConfig }: SlackProps): JSX.Element {
const { t } = useTranslation('channels');
return (
<>
<Form.Item
name="apiUrl"
name="api_url"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -41,7 +30,7 @@ function Slack({
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
apiUrl: event.target.value,
api_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -78,18 +67,6 @@ function Slack({
/>
</Form.Item>
<Form.Item name="titleLink" label={t('field_slack_title_link')}>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
titleLink: event.target.value,
}))
}
data-testid="title-link-textbox"
/>
</Form.Item>
<Form.Item name="text" label={t('field_slack_description')}>
<TextArea
onChange={(event): void =>
@@ -102,85 +79,12 @@ function Slack({
data-testid="description-textarea"
/>
</Form.Item>
<Form.Item
name="color"
label={t('field_slack_color')}
help={t('help_slack_color')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
color: event.target.value,
}))
}
placeholder={t('placeholder_slack_color')}
data-testid="slack-color-textbox"
/>
</Form.Item>
<Form.Item
name="pretext"
label={t('field_slack_pretext')}
help={t('help_slack_pretext')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
pretext: event.target.value,
}))
}
data-testid="slack-pretext-textbox"
/>
</Form.Item>
<Form.Item
name="fallback"
label={t('field_slack_fallback')}
help={t('help_slack_fallback')}
>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
fallback: event.target.value,
}))
}
data-testid="slack-fallback-textbox"
/>
</Form.Item>
<Form.Item name="footer" label={t('field_slack_footer')}>
<Input
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
footer: event.target.value,
}))
}
data-testid="slack-footer-textbox"
/>
</Form.Item>
<SlackFields
setSelectedConfig={setSelectedConfig}
initialFields={initialFields}
/>
<SlackActions
setSelectedConfig={setSelectedConfig}
initialActions={initialActions}
/>
</>
);
}
interface SlackProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
setSelectedConfig: Dispatch<SetStateAction<Partial<SlackChannel>>>;
}
export default Slack;

View File

@@ -1,123 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Form, Input } from 'antd';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface SlackActionsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialActions?: AlertmanagertypesChannelSlackActionDTO[];
}
const emptyAction: AlertmanagertypesChannelSlackActionDTO = {
type: 'button',
text: '',
url: '',
};
// Buttons Slack renders under the attachment. `type` and `text` are required by
// the API; a `button` carrying a url is the link-out case, the rest drive a
// Slack app's own callbacks.
function SlackActions({
setSelectedConfig,
initialActions,
}: SlackActionsProps): JSX.Element {
const { t } = useTranslation('channels');
const [rows, setRows] = useState<AlertmanagertypesChannelSlackActionDTO[]>(
() => initialActions ?? [],
);
const sync = (next: AlertmanagertypesChannelSlackActionDTO[]): void => {
setRows(next);
setSelectedConfig((value) => ({
...value,
actions: next.filter((row) => row.text.trim() !== ''),
}));
};
const updateRow = (
index: number,
patch: Partial<AlertmanagertypesChannelSlackActionDTO>,
): void =>
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<Form.Item label={t('field_slack_actions')} help={t('help_slack_actions')}>
{rows.map((row, index) => (
// the rows have no stable id, and reordering is not offered
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="slack-actions-row">
<Input
value={row.text}
placeholder={t('placeholder_slack_action_text')}
onChange={(event): void => updateRow(index, { text: event.target.value })}
data-testid={`slack-action-text-${index}`}
/>
<Input
value={row.url}
placeholder={t('placeholder_slack_action_url')}
onChange={(event): void => updateRow(index, { url: event.target.value })}
data-testid={`slack-action-url-${index}`}
/>
<Input
value={row.type}
placeholder={t('placeholder_slack_action_type')}
onChange={(event): void => updateRow(index, { type: event.target.value })}
data-testid={`slack-action-type-${index}`}
/>
<Input
value={row.name ?? ''}
placeholder={t('placeholder_slack_action_name')}
onChange={(event): void => updateRow(index, { name: event.target.value })}
data-testid={`slack-action-name-${index}`}
/>
<Input
value={row.value ?? ''}
placeholder={t('placeholder_slack_action_value')}
onChange={(event): void =>
updateRow(index, { value: event.target.value })
}
data-testid={`slack-action-value-${index}`}
/>
<Input
value={row.style ?? ''}
placeholder={t('placeholder_slack_action_style')}
onChange={(event): void =>
updateRow(index, { style: event.target.value })
}
data-testid={`slack-action-style-${index}`}
/>
<Input
value={row.confirm?.text ?? ''}
placeholder={t('placeholder_slack_action_confirm')}
onChange={(event): void =>
updateRow(index, {
confirm: event.target.value ? { text: event.target.value } : undefined,
})
}
data-testid={`slack-action-confirm-${index}`}
/>
<Button
type="text"
icon={<Minus size={14} />}
aria-label={t('remove_slack_action')}
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
data-testid={`slack-action-remove-${index}`}
/>
</div>
))}
<Button
type="dashed"
icon={<Plus size={14} />}
onClick={(): void => sync([...rows, { ...emptyAction }])}
data-testid="slack-action-add"
>
{t('add_slack_action')}
</Button>
</Form.Item>
);
}
export default SlackActions;

View File

@@ -1,95 +0,0 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Minus, Plus } from '@signozhq/icons';
import { Button, Checkbox, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackFieldDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
interface SlackFieldsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialFields?: AlertmanagertypesChannelSlackFieldDTO[];
}
// Slack renders these as the attachment's table of short or full-width entries.
function SlackFields({
setSelectedConfig,
initialFields,
}: SlackFieldsProps): JSX.Element {
const { t } = useTranslation('channels');
const [rows, setRows] = useState<AlertmanagertypesChannelSlackFieldDTO[]>(
() => initialFields ?? [],
);
const sync = (next: AlertmanagertypesChannelSlackFieldDTO[]): void => {
setRows(next);
setSelectedConfig((value) => ({
...value,
fields: next.filter((row) => row.title.trim() !== ''),
}));
};
const updateRow = (
index: number,
patch: Partial<AlertmanagertypesChannelSlackFieldDTO>,
): void =>
sync(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
return (
<Form.Item label={t('field_slack_fields')} help={t('help_slack_fields')}>
{rows.map((row, index) => (
// the rows have no stable id, and reordering is not offered
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="slack-fields-row">
<Input
value={row.title}
placeholder={t('placeholder_slack_field_title')}
onChange={(event): void =>
updateRow(index, { title: event.target.value })
}
data-testid={`slack-field-title-${index}`}
/>
<Input
value={row.value}
placeholder={t('placeholder_slack_field_value')}
onChange={(event): void =>
updateRow(index, { value: event.target.value })
}
data-testid={`slack-field-value-${index}`}
/>
<Checkbox
checked={!!row.short}
onChange={(event): void =>
updateRow(index, { short: event.target.checked })
}
data-testid={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
<Button
type="text"
icon={<Minus size={14} />}
aria-label={t('remove_slack_field')}
onClick={(): void => sync(rows.filter((_, i) => i !== index))}
data-testid={`slack-field-remove-${index}`}
/>
</div>
))}
<Button
type="dashed"
icon={<Plus size={14} />}
onClick={(): void =>
sync([...rows, { title: '', value: '', short: false }])
}
data-testid="slack-field-add"
>
{t('add_slack_field')}
</Button>
</Form.Item>
);
}
export default SlackFields;

View File

@@ -1,148 +0,0 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
/**
* The API models the chat and the topic as integers, so a half-typed or cleared
* box has to send nothing rather than NaN, which would serialise as null and be
* rejected.
*/
const toNumber = (value: string): number | undefined => {
const parsed = Number(value);
return value.trim() === '' || Number.isNaN(parsed) ? undefined : parsed;
};
function TelegramSettings({
setSelectedConfig,
initialDisableNotifications,
}: TelegramProps): JSX.Element {
const { t } = useTranslation('channels');
const update = (patch: Partial<ChannelSpecFormValues>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
return (
<>
<Typography.Text
color="muted"
size="sm"
testId="telegram-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('telegram_tip')}{' '}
<Typography.Link
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
>
{t('telegram_tip_link')}
</Typography.Link>
</Typography.Text>
<Form.Item
name="botToken"
label={t('field_telegram_bot_token')}
help={t('help_telegram_bot_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ botToken: event.target.value })}
data-testid="telegram-bot-token-textbox"
/>
</Form.Item>
<Form.Item
name="chatId"
label={t('field_telegram_chat_id')}
help={t('help_telegram_chat_id')}
required
>
<Input
type="number"
placeholder={t('placeholder_telegram_chat_id')}
onChange={(event): void =>
update({ chatId: toNumber(event.target.value) })
}
data-testid="telegram-chat-id-textbox"
/>
</Form.Item>
<Form.Item
name="messageThreadId"
label={t('field_telegram_message_thread_id')}
help={t('help_telegram_message_thread_id')}
>
<Input
type="number"
onChange={(event): void =>
update({ messageThreadId: toNumber(event.target.value) })
}
data-testid="telegram-message-thread-id-textbox"
/>
</Form.Item>
<Form.Item
name="title"
label={t('field_telegram_title')}
help={t('help_telegram_title')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ title: event.target.value })}
data-testid="telegram-title-textarea"
/>
</Form.Item>
<Form.Item
name="message"
label={t('field_telegram_message')}
help={t('help_telegram_message')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ message: event.target.value })}
data-testid="telegram-message-textarea"
/>
</Form.Item>
<Form.Item
name="apiUrl"
label={t('field_telegram_api_url')}
help={t('help_telegram_api_url')}
>
<Input
onChange={(event): void => update({ apiUrl: event.target.value })}
data-testid="telegram-api-url-textbox"
/>
</Form.Item>
<Form.Item
name="disableNotifications"
label={t('field_telegram_disable_notifications')}
help={t('help_telegram_disable_notifications')}
>
<Switch
defaultValue={initialDisableNotifications}
testId="telegram-disable-notifications-switch"
onChange={(value): void => update({ disableNotifications: value })}
/>
</Form.Item>
</>
);
}
interface TelegramProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
initialDisableNotifications?: boolean;
}
TelegramSettings.defaultProps = {
initialDisableNotifications: undefined,
};
export default TelegramSettings;

View File

@@ -4,7 +4,7 @@ import { Input } from '@signozhq/ui/input';
import { Form } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import { WebhookChannel } from '../../CreateAlertChannels/config';
function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
const { t } = useTranslation('channels');
@@ -12,7 +12,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
return (
<>
<Form.Item
name="url"
name="api_url"
label={t('field_webhook_url')}
tooltip={{
title: (
@@ -29,7 +29,7 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
url: event.target.value,
api_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
@@ -66,28 +66,12 @@ function WebhookSettings({ setSelectedConfig }: WebhookProps): JSX.Element {
data-testid="webhook-password-textbox"
/>
</Form.Item>
<Form.Item
name="bearerToken"
label={t('field_webhook_bearer_token')}
help={t('help_webhook_bearer_token')}
>
<Input
type="password"
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
bearerToken: event.target.value,
}));
}}
data-testid="webhook-bearer-token-textbox"
/>
</Form.Item>
</>
);
}
interface WebhookProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<ChannelSpecFormValues>>>;
setSelectedConfig: Dispatch<SetStateAction<Partial<WebhookChannel>>>;
}
export default WebhookSettings;

View File

@@ -7,11 +7,17 @@ import { Typography } from '@signozhq/ui/typography';
import type { Store } from 'antd/lib/form/interface';
import ROUTES from 'constants/routes';
import {
ChannelKind,
ChannelSpecFormValues,
} from 'container/CreateAlertChannels/types';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
ChannelType,
EmailChannel,
GoogleChatChannel,
IncidentIOChannel,
JiraChannel,
JsmOpsChannel,
OpsgenieChannel,
PagerChannel,
SlackChannel,
WebhookChannel,
} from 'container/CreateAlertChannels/config';
import history from 'lib/history';
import EmailSettings from './Settings/Email';
@@ -23,7 +29,6 @@ import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import TelegramSettings from './Settings/Telegram';
import WebhookSettings from './Settings/Webhook';
import { Button } from './styles';
@@ -39,62 +44,35 @@ function FormAlertChannels({
title,
initialValue,
editing = false,
readOnly = false,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
const { t } = useTranslation('channels');
const renderSettings = (): ReactElement | null => {
switch (type) {
case ChannelKind.slack:
return (
<SlackSettings
setSelectedConfig={setSelectedConfig}
initialFields={
(initialValue?.fields as ChannelSpecFormValues['fields']) ?? undefined
}
initialActions={
(initialValue?.actions as ChannelSpecFormValues['actions']) ?? undefined
}
/>
);
case ChannelKind.webhook:
case ChannelType.Slack:
return <SlackSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.Webhook:
return <WebhookSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.pagerduty:
return (
<PagerSettings
setSelectedConfig={setSelectedConfig}
initialDetails={initialValue?.details as Record<string, string>}
/>
);
case ChannelKind.msteams:
case ChannelType.Pagerduty:
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
case ChannelType.MsTeams:
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.googlechat:
case ChannelType.GoogleChat:
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.jira:
case ChannelType.Jira:
return <JiraSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.jsmops:
case ChannelType.JsmOps:
return <JsmOpsSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.incidentio:
case ChannelType.IncidentIO:
return (
<IncidentIOSettings
setSelectedConfig={setSelectedConfig}
initialMetadata={initialValue?.metadata as Record<string, string>}
/>
);
case ChannelKind.telegram:
return (
<TelegramSettings
setSelectedConfig={setSelectedConfig}
initialDisableNotifications={
initialValue?.disableNotifications as boolean
}
/>
);
case ChannelKind.opsgenie:
case ChannelType.Opsgenie:
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
case ChannelKind.email:
case ChannelType.Email:
return <EmailSettings setSelectedConfig={setSelectedConfig} />;
default:
return null;
@@ -107,18 +85,8 @@ function FormAlertChannels({
{title}
</Typography.Title>
<Form
initialValues={initialValue}
layout="vertical"
form={formInstance}
disabled={readOnly}
>
<Form.Item
label={t('field_channel_name')}
labelAlign="left"
name="name"
extra={editing ? t('help_channel_name_immutable') : undefined}
>
<Form initialValues={initialValue} layout="vertical" form={formInstance}>
<Form.Item label={t('field_channel_name')} labelAlign="left" name="name">
<Input
data-testid="channel-name-textbox"
disabled={editing}
@@ -134,15 +102,15 @@ function FormAlertChannels({
<Form.Item
label={t('field_send_resolved')}
labelAlign="left"
name="sendResolved"
name="send_resolved"
>
<Switch
defaultValue={initialValue?.sendResolved}
defaultValue={initialValue?.send_resolved}
testId="field-send-resolved-checkbox"
onChange={(value): void => {
setSelectedConfig((state) => ({
...state,
sendResolved: value,
send_resolved: value,
}));
}}
/>
@@ -153,9 +121,6 @@ function FormAlertChannels({
disabled={editing}
onChange={onTypeChangeHandler}
value={type}
// the list is virtualized, and the default height clips the last
// kinds out of the rendered window
listHeight={420}
data-testid="channel-type-select"
>
<Select.Option value="slack" key="slack" data-testid="select-option">
@@ -209,44 +174,31 @@ function FormAlertChannels({
>
incident.io
</Select.Option>
<Select.Option
value="telegram"
key="telegram"
data-testid="select-option"
>
Telegram
</Select.Option>
</Select>
</Form.Item>
<Form.Item>{renderSettings()}</Form.Item>
<Form.Item>
<AuthZTooltip checks={saveChecks}>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
onClick={(): void => onSaveHandler(type)}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<Button
data-testid="save-channel-button"
disabled={savingState}
loading={savingState}
type="primary"
onClick={(): void => onSaveHandler(type)}
>
{t('button_save_channel')}
</Button>
<Button
data-testid="test-channel-button"
disabled={testingState}
loading={testingState}
onClick={(): void => onTestHandler(type)}
>
{t('button_test_channel')}
</Button>
<Button
data-testid="return-button"
disabled={false}
onClick={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
@@ -261,30 +213,35 @@ function FormAlertChannels({
interface FormAlertChannelsProps {
formInstance: FormInstance;
type: ChannelKind;
setSelectedConfig: Dispatch<SetStateAction<ChannelSpecFormValues>>;
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: (props: ChannelKind) => void;
onTestHandler: (props: ChannelKind) => void;
type: ChannelType;
setSelectedConfig: Dispatch<
SetStateAction<
Partial<
SlackChannel &
WebhookChannel &
PagerChannel &
OpsgenieChannel &
EmailChannel &
GoogleChatChannel &
JiraChannel &
JsmOpsChannel &
IncidentIOChannel
>
>
>;
onTypeChangeHandler: (value: ChannelType) => void;
onSaveHandler: (props: ChannelType) => void;
onTestHandler: (props: ChannelType) => void;
testingState: boolean;
savingState: boolean;
title: string;
initialValue: Store;
// 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;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
testChecks?: BrandedPermission[];
}
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
saveChecks: [],
testChecks: [],
};
export default FormAlertChannels;

View File

@@ -1,15 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Button, Flex, Form, Select, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import ROUTES from 'constants/routes';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
@@ -43,16 +47,18 @@ function BasicInfo({
}: BasicInfoProps): JSX.Element {
const { t } = useTranslation('alerts');
const {
isLoading,
data: channels,
error,
isError,
refetch,
} = useChannelOptions();
const { isLoading, data, error, isError, refetch } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
useState(false);
@@ -75,7 +81,7 @@ function BasicInfo({
});
};
const noChannels = channels.length === 0;
const noChannels = data?.data?.length === 0;
const handleCreateNewChannels = useCallback(() => {
logEvent('Alert: Create notification channel button clicked', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
@@ -90,7 +96,7 @@ function BasicInfo({
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
logEvent('Alert: New alert creation page visited', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
numberOfChannels: channels.length,
numberOfChannels: data?.data?.length,
});
hasLoggedEvent.current = true;
}
@@ -226,7 +232,7 @@ function BasicInfo({
disabled={shouldBroadCastToAllChannels}
currentValue={alertDef.preferredChannels}
handleCreateNewChannels={handleCreateNewChannels}
channels={channels}
channels={data?.data || []}
isLoading={isLoading}
hasError={isError}
error={error as APIError}

View File

@@ -2,9 +2,10 @@ import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Select, Spin } from 'antd';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { StyledCreateChannelOption, StyledSelect } from './styles';
@@ -15,7 +16,7 @@ export interface ChannelSelectProps {
onSelectChannels: (s: string[]) => void;
onDropdownOpen: () => void;
isLoading: boolean;
channels: ChannelOption[];
channels: Channels[];
hasError: boolean;
error: APIError;
handleCreateNewChannels: () => void;
@@ -52,8 +53,11 @@ function ChannelSelect({
});
}
const { canCreate: addNewChannelPermission } =
useNotificationChannelCollectionPermissions();
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
['add_new_channel'],
user.role,
);
const renderOptions = (): ReactNode[] => {
const children: ReactNode[] = [];

View File

@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(12);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -337,7 +337,6 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'notification-channel',
'quick-filter',
'role',
'serviceaccount',
@@ -423,7 +422,7 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(12);
expect(result).toHaveLength(11);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -431,7 +430,6 @@ describe('createEmptyRolePermissions', () => {
'logs',
'meter-metrics',
'metrics',
'notification-channel',
'quick-filter',
'role',
'serviceaccount',

View File

@@ -1,6 +1,5 @@
import {
Bot,
Cable,
ChartLine,
DraftingCompass,
FileKey,
@@ -96,15 +95,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type quick filter ID, separate multiple with comma or space',
docsAnchor: 'quick-filter',
},
'notification-channel': {
label: 'Notification Channels',
description:
'Destinations for alert notifications, such as Slack, PagerDuty, webhooks, etc.',
icon: Cable,
selectorPlaceholder:
'Type notification channel ID, separate multiple with comma or space',
docsAnchor: 'notification-channel',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',

View File

@@ -1,6 +1,6 @@
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
import { IAppContext, IUser } from 'providers/App/types';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
@@ -28,13 +28,21 @@ export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
updatedBy: 'user2@signoz.io',
};
export const MOCK_CHANNEL_1: ChannelOption = {
export const MOCK_CHANNEL_1: Channels = {
name: 'Channel 1',
created_at: '2021-01-01',
data: 'data 1',
id: '1',
type: 'type 1',
updated_at: '2021-01-01',
};
export const MOCK_CHANNEL_2: ChannelOption = {
export const MOCK_CHANNEL_2: Channels = {
name: 'Channel 2',
created_at: '2021-01-02',
data: 'data 2',
id: '2',
type: 'type 2',
updated_at: '2021-01-02',
};
export function getUseRoutingPoliciesMockData(

View File

@@ -77,14 +77,12 @@ jest.mock('hooks/routingPolicies/useDeleteRoutingPolicy', () => ({
isLoading: false,
}),
}));
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
jest.mock('api/channels/getAll', () => ({
__esModule: true,
useChannelOptions: (): any => ({
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
isLoading: false,
isError: false,
refetch: jest.fn(),
}),
default: (): any =>
Promise.resolve({
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
}),
}));
const ROUTING_POLICY_1_NAME = 'Routing Policy 1';

View File

@@ -1,4 +1,4 @@
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { Channels } from 'types/api/channels/getAll';
export interface RoutingPolicy {
id: string;
@@ -62,7 +62,7 @@ export interface RoutingPolicyDetailsProps {
routingPolicy: RoutingPolicy | null;
closeModal: () => void;
mode: PolicyDetailsModalMode;
channels: ChannelOption[];
channels: Channels[];
isErrorChannels: boolean;
isLoadingChannels: boolean;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
@@ -86,7 +86,7 @@ export interface UseRoutingPoliciesReturn {
isErrorRoutingPolicies: boolean;
refetchRoutingPolicies: () => void;
// Channels
channels: ChannelOption[];
channels: Channels[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -1,17 +1,19 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useQuery, useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import getAllChannels from 'api/channels/getAll';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import useUrlQuery from 'hooks/useUrlQuery';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import {
PolicyDetailsModalMode,
@@ -81,11 +83,14 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
// Channels list
const {
data: channels,
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refetchChannels,
} = useChannelOptions();
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const refreshChannels = (): void => {
refetchChannels();

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -1,68 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import { listNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
/** The list API's own ceiling; a bigger limit is clamped to it server-side. */
const MAX_PAGE_SIZE = 200;
export const CHANNEL_OPTIONS_QUERY_KEY = ['notificationChannelOptions'];
export interface ChannelOption {
id: string;
/** The display name, which is what rules and routing policies reference. */
name: string;
}
export type UseChannelOptionsResult = Omit<
UseQueryResult<ChannelOption[], Error>,
'data'
> & {
data: ChannelOption[];
};
function fetchPage(
offset: number,
): ReturnType<typeof listNotificationChannels> {
return listNotificationChannels({
limit: MAX_PAGE_SIZE,
offset,
sort: AlertmanagertypesChannelListSortDTO.name,
order: AlertmanagertypesChannelListOrderDTO.asc,
});
}
/**
* Every channel, for the pickers that let a rule or a policy name one. The list
* API pages at 200, so the first page reports the total and the rest are
* fetched together rather than silently truncating.
*/
async function fetchAllChannels(): Promise<ChannelOption[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(MAX_PAGE_SIZE, first.data.total, MAX_PAGE_SIZE).map(fetchPage),
);
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [
...first.data.channels,
...rest.flatMap((page) => page.data.channels),
];
return channels.map((channel) => ({
id: channel.id,
name: channel.displayName,
}));
}
export function useChannelOptions(): UseChannelOptionsResult {
const query = useQuery<ChannelOption[], Error>(
CHANNEL_OPTIONS_QUERY_KEY,
fetchAllChannels,
);
return { ...query, data: query.data ?? [] };
}

View File

@@ -1,39 +0,0 @@
import {
NotificationChannelCreatePermission,
NotificationChannelListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export interface NotificationChannelCollectionPermissions {
canList: boolean;
canCreate: boolean;
/** A test send is gated on `create` against the wildcard. */
canTest: boolean;
isLoading: boolean;
/**
* The check itself failed. Callers should fall open (behave as before authz
* and let the API decide) rather than treat an outage as a denial.
*/
hasError: boolean;
}
// Module-level so the useQueries identity stays stable across renders.
const CHECKS = [
NotificationChannelListPermission,
NotificationChannelCreatePermission,
];
/** Collection-level notification channel permissions (wildcard selector). */
export function useNotificationChannelCollectionPermissions(): NotificationChannelCollectionPermissions {
const { isGranted, isLoading, error } = useAuthZ(CHECKS);
const canCreate = isGranted(NotificationChannelCreatePermission);
return {
canList: isGranted(NotificationChannelListPermission),
canCreate,
canTest: canCreate,
isLoading,
hasError: !!error,
};
}

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