Compare commits

..

8 Commits

Author SHA1 Message Date
Gaurav Tewari
8eb33ef05b test(trace-details): add useTraceThread hook tests 2026-10-20 18:45:02 +05:30
Gaurav Tewari
2429549df9 Merge branch 'feat/trace-details-thread' into feat/trace-details-thread-tests 2026-10-20 18:44:32 +05:30
Gaurav Tewari
681e96df82 fix: ux issue (empty menu items + refetch on scroll) 2026-10-20 18:42:42 +05:30
Gaurav Tewari
ef36279e1c chore: minor ux improvements 2026-10-20 13:41:30 +05:30
Gaurav Tewari
fc62476e6b test(trace-details): add AI thread view tests and stories 2026-10-19 11:56:16 +05:30
Gaurav Tewari
c64af15ac6 feat(trace-details): add AI thread view 2026-10-19 11:56:16 +05:30
Gaurav Tewari
f45d683ec2 feat(trace-details): trace details & thread tab 2026-10-19 11:56:16 +05:30
Gaurav Tewari
edca5d2e3e refactor(trace-details): minor refactor 2026-10-19 11:56:16 +05:30
344 changed files with 12844 additions and 10410 deletions

2
.github/CODEOWNERS vendored
View File

@@ -213,7 +213,9 @@ go.mod @therealpandey
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend

View File

@@ -2,13 +2,25 @@ name: e2eci
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
if: github.event_name != 'merge_group'
runs-on: ubuntu-latest
outputs:
e2e: ${{ steps.changes.outputs.e2e }}
@@ -18,21 +30,17 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FORCE: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
run: |
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select(startswith("tests/integration/") | not)')
PATTERN='^(frontend/|tests/|\.github/workflows/e2eci\.yaml$|cmd/enterprise/Dockerfile\.with-web\.integration$)'
if [ "$FORCE" = "true" ]; then
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
echo "e2e=true" >> "$GITHUB_OUTPUT"
elif printf '%s\n' "$CHANGED_FILES" | grep -qE "$PATTERN"; then
echo "e2e=true" >> "$GITHUB_OUTPUT"
else
echo "e2e=false" >> "$GITHUB_OUTPUT"
fi
fmtlint:
needs: changes
if: needs.changes.outputs.e2e == 'true'
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -62,7 +70,9 @@ jobs:
- chromium
shard: [1, 2, 3, 4]
needs: changes
if: needs.changes.outputs.e2e == 'true'
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -171,14 +181,17 @@ jobs:
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
path: tests/e2e/artifacts/
retention-days: 5
# Required status check. Fails when e2e was needed but did not pass.
# Required status check. Fails when e2e was needed but did not pass,
# including fork and dependabot PRs still waiting on safe-to-test.
e2e:
if: always()
needs: [changes, fmtlint, test]
runs-on: ubuntu-latest
steps:
- name: verdict
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
if: |
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
run: |
echo "::error::e2e did not pass."
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
exit 1

View File

@@ -93,19 +93,18 @@ 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.ResourceMetaResourceSavedView).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,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).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:
@@ -1442,13 +1374,9 @@ components:
AuthtypesAuthDomainConfigGoogle:
properties:
kind:
allOf:
- $ref: '#/components/schemas/AuthtypesAuthNProvider'
description: The kind of authn provider.
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
allOf:
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
description: The google auth configuration.
$ref: '#/components/schemas/AuthtypesGoogleConfig'
required:
- kind
- spec
@@ -1456,13 +1384,9 @@ components:
AuthtypesAuthDomainConfigOIDC:
properties:
kind:
allOf:
- $ref: '#/components/schemas/AuthtypesAuthNProvider'
description: The kind of authn provider.
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
allOf:
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
description: The oidc configuration.
$ref: '#/components/schemas/AuthtypesOIDCConfig'
required:
- kind
- spec
@@ -1470,13 +1394,9 @@ components:
AuthtypesAuthDomainConfigSAML:
properties:
kind:
allOf:
- $ref: '#/components/schemas/AuthtypesAuthNProvider'
description: The kind of authn provider.
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
allOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
description: The saml configuration.
$ref: '#/components/schemas/AuthtypesSamlConfig'
required:
- kind
- spec
@@ -8232,13 +8152,9 @@ components:
Querybuildertypesv5BucketOptionsLinear:
properties:
kind:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
description: How the upper bounds are spaced.
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
description: The evenly spaced bucket specification.
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
required:
- kind
- spec
@@ -8246,13 +8162,9 @@ components:
Querybuildertypesv5BucketOptionsLog:
properties:
kind:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
description: How the upper bounds are spaced.
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
description: The logarithmic bucket specification.
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
required:
- kind
- spec
@@ -8841,78 +8753,54 @@ components:
Querybuildertypesv5QueryEnvelopeBuilder:
properties:
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5BuilderQuerySpec'
description: The builder query specification.
$ref: '#/components/schemas/Querybuildertypesv5BuilderQuerySpec'
type:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeBuilderAI:
properties:
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
description: The AI builder query specification.
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
description: The ClickHouse SQL query specification.
$ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
type:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeFormula:
properties:
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
description: The formula specification.
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
type:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopePromQL:
properties:
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5PromQuery'
description: The PromQL query specification.
$ref: '#/components/schemas/Querybuildertypesv5PromQuery'
type:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeTraceOperator:
properties:
spec:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
description: The trace operator specification.
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
type:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
@@ -9472,13 +9360,9 @@ components:
RuletypesEvaluationCumulative:
properties:
kind:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationKind'
description: The kind of evaluation.
$ref: '#/components/schemas/RuletypesEvaluationKind'
spec:
allOf:
- $ref: '#/components/schemas/RuletypesCumulativeWindow'
description: The cumulative window evaluation specification.
$ref: '#/components/schemas/RuletypesCumulativeWindow'
required:
- kind
- spec
@@ -9501,13 +9385,9 @@ components:
RuletypesEvaluationRolling:
properties:
kind:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationKind'
description: The kind of evaluation.
$ref: '#/components/schemas/RuletypesEvaluationKind'
spec:
allOf:
- $ref: '#/components/schemas/RuletypesRollingWindow'
description: The rolling window evaluation specification.
$ref: '#/components/schemas/RuletypesRollingWindow'
required:
- kind
- spec
@@ -9888,13 +9768,9 @@ components:
RuletypesThresholdBasic:
properties:
kind:
allOf:
- $ref: '#/components/schemas/RuletypesThresholdKind'
description: The kind of threshold.
$ref: '#/components/schemas/RuletypesThresholdKind'
spec:
allOf:
- $ref: '#/components/schemas/RuletypesBasicRuleThresholds'
description: The basic threshold specification (array of thresholds).
$ref: '#/components/schemas/RuletypesBasicRuleThresholds'
required:
- kind
- spec

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

@@ -262,20 +262,23 @@
// React Hooks rules - Enforces Rules of Hooks (only call at top level)
"react-hooks/exhaustive-deps": "warn",
// Warns about missing dependencies in useEffect/useMemo/useCallback
// React Compiler rules, off because we don't have React Compiler enabled
"react/error-boundaries": "off",
"react/globals": "off",
"react/immutability": "off",
"react/incompatible-library": "off",
"react/preserve-manual-memoization": "off",
"react/purity": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/set-state-in-render": "off",
"react/static-components": "off",
"react/unsupported-syntax": "off",
"react/use-memo": "off",
"react/void-use-memo": "off",
// NOTE: The following react-hooks rules are not supported right know, follow the progress at https://github.com/oxc-project/oxc/issues/1022
// Most of them are for React Compiler, which we don't have enabled right know
// "react-hooks/config": "error",
// "react-hooks/error-boundaries": "error",
// "react-hooks/component-hook-factories": "error",
// "react-hooks/gating": "error",
// "react-hooks/globals": "error",
// "react-hooks/immutability": "error",
// "react-hooks/preserve-manual-memoization": "error",
// "react-hooks/purity": "error",
// "react-hooks/refs": "error",
// "react-hooks/set-state-in-effect": "error",
// "react-hooks/set-state-in-render": "error",
// "react-hooks/static-components": "error",
// "react-hooks/unsupported-syntax": "warn",
// "react-hooks/use-memo": "error",
// "react-hooks/incompatible-library": "warn",
"signoz/no-unsupported-asset-pattern": "error",
// Prevents the wrong usage of assets to break custom base path installations
"signoz/no-zustand-getstate-in-hooks": "error",
@@ -579,16 +582,6 @@
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"
}
},
{
// TypeScript reports real redeclarations; this rule also flags `const X` + `type X` pairs.
"files": [
"**/*.ts",
"**/*.tsx"
],
"rules": {
"no-redeclare": "off"
}
}
]
}

View File

@@ -119,7 +119,7 @@ const preview: Preview = {
'Planned Downtime',
'Routing Policies',
'Channels',
['List', 'List Authz', 'New', 'New Authz', 'Edit', 'Edit Authz'],
['List', 'New', 'Edit'],
],
'Dashboards',
[
@@ -142,7 +142,7 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
['Explorer', 'Trace Details', ['Overview', 'Thread'], 'Funnel Details'],
'Metrics',
['Explorer'],
'Infrastructure',

View File

@@ -204,9 +204,9 @@
"lint-staged": "^17.0.4",
"msw": "1.3.2",
"orval": "8.22.0",
"oxfmt": "0.73.0",
"oxlint": "1.88.0",
"oxlint-tsgolint": "7.0.2004",
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
"postcss": "8.5.26",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",

403
frontend/pnpm-lock.yaml generated
View File

@@ -487,14 +487,14 @@ importers:
specifier: 8.22.0
version: 8.22.0(prettier@3.8.3)(typescript@5.9.3)
oxfmt:
specifier: 0.73.0
version: 0.73.0
specifier: 0.54.0
version: 0.54.0
oxlint:
specifier: 1.88.0
version: 1.88.0(oxlint-tsgolint@7.0.2004)
specifier: 1.69.0
version: 1.69.0(oxlint-tsgolint@0.23.0)
oxlint-tsgolint:
specifier: 7.0.2004
version: 7.0.2004
specifier: 0.23.0
version: 0.23.0
postcss:
specifier: 8.5.26
version: 8.5.26
@@ -533,7 +533,7 @@ importers:
version: 1.6.0(react@18.2.0)
vite-plugin-checker:
specifier: 0.12.0
version: 0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.88.0(oxlint-tsgolint@7.0.2004))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(stylelint@17.15.0(typescript@5.9.3))(typescript@5.9.3)
version: 0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(stylelint@17.15.0(typescript@5.9.3))(typescript@5.9.3)
vite-plugin-compression:
specifier: 0.5.1
version: 0.5.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))
@@ -2555,276 +2555,276 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/binding-android-arm-eabi@0.73.0':
resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==}
'@oxfmt/binding-android-arm-eabi@0.54.0':
resolution: {integrity: sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.73.0':
resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==}
'@oxfmt/binding-android-arm64@0.54.0':
resolution: {integrity: sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.73.0':
resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==}
'@oxfmt/binding-darwin-arm64@0.54.0':
resolution: {integrity: sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.73.0':
resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==}
'@oxfmt/binding-darwin-x64@0.54.0':
resolution: {integrity: sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.73.0':
resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==}
'@oxfmt/binding-freebsd-x64@0.54.0':
resolution: {integrity: sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.73.0':
resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==}
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
resolution: {integrity: sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.73.0':
resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==}
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
resolution: {integrity: sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.73.0':
resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==}
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
resolution: {integrity: sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.73.0':
resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==}
'@oxfmt/binding-linux-arm64-musl@0.54.0':
resolution: {integrity: sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.73.0':
resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==}
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
resolution: {integrity: sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.73.0':
resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==}
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
resolution: {integrity: sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.73.0':
resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==}
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
resolution: {integrity: sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.73.0':
resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==}
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
resolution: {integrity: sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.73.0':
resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==}
'@oxfmt/binding-linux-x64-gnu@0.54.0':
resolution: {integrity: sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.73.0':
resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==}
'@oxfmt/binding-linux-x64-musl@0.54.0':
resolution: {integrity: sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.73.0':
resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==}
'@oxfmt/binding-openharmony-arm64@0.54.0':
resolution: {integrity: sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.73.0':
resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==}
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
resolution: {integrity: sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.73.0':
resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==}
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
resolution: {integrity: sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.73.0':
resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==}
'@oxfmt/binding-win32-x64-msvc@0.54.0':
resolution: {integrity: sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@7.0.2004':
resolution: {integrity: sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==}
'@oxlint-tsgolint/darwin-arm64@0.23.0':
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@7.0.2004':
resolution: {integrity: sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==}
'@oxlint-tsgolint/darwin-x64@0.23.0':
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@7.0.2004':
resolution: {integrity: sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==}
'@oxlint-tsgolint/linux-arm64@0.23.0':
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@7.0.2004':
resolution: {integrity: sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==}
'@oxlint-tsgolint/linux-x64@0.23.0':
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@7.0.2004':
resolution: {integrity: sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==}
'@oxlint-tsgolint/win32-arm64@0.23.0':
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@7.0.2004':
resolution: {integrity: sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==}
'@oxlint-tsgolint/win32-x64@0.23.0':
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.88.0':
resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==}
'@oxlint/binding-android-arm-eabi@1.69.0':
resolution: {integrity: sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.88.0':
resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==}
'@oxlint/binding-android-arm64@1.69.0':
resolution: {integrity: sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.88.0':
resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==}
'@oxlint/binding-darwin-arm64@1.69.0':
resolution: {integrity: sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.88.0':
resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==}
'@oxlint/binding-darwin-x64@1.69.0':
resolution: {integrity: sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.88.0':
resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==}
'@oxlint/binding-freebsd-x64@1.69.0':
resolution: {integrity: sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.88.0':
resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==}
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
resolution: {integrity: sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.88.0':
resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==}
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
resolution: {integrity: sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.88.0':
resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==}
'@oxlint/binding-linux-arm64-gnu@1.69.0':
resolution: {integrity: sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.88.0':
resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==}
'@oxlint/binding-linux-arm64-musl@1.69.0':
resolution: {integrity: sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.88.0':
resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==}
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
resolution: {integrity: sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.88.0':
resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==}
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
resolution: {integrity: sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.88.0':
resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==}
'@oxlint/binding-linux-riscv64-musl@1.69.0':
resolution: {integrity: sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.88.0':
resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==}
'@oxlint/binding-linux-s390x-gnu@1.69.0':
resolution: {integrity: sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.88.0':
resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==}
'@oxlint/binding-linux-x64-gnu@1.69.0':
resolution: {integrity: sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.88.0':
resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==}
'@oxlint/binding-linux-x64-musl@1.69.0':
resolution: {integrity: sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.88.0':
resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==}
'@oxlint/binding-openharmony-arm64@1.69.0':
resolution: {integrity: sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.88.0':
resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==}
'@oxlint/binding-win32-arm64-msvc@1.69.0':
resolution: {integrity: sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.88.0':
resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==}
'@oxlint/binding-win32-ia32-msvc@1.69.0':
resolution: {integrity: sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.88.0':
resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==}
'@oxlint/binding-win32-x64-msvc@1.69.0':
resolution: {integrity: sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -7785,32 +7785,29 @@ packages:
oxc-resolver@11.24.2:
resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==}
oxfmt@0.73.0:
resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==}
oxfmt@0.54.0:
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@astrojs/compiler-rs': ^0.4.0 || ^0.5.0
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
'@astrojs/compiler-rs':
optional: true
svelte:
optional: true
vite-plus:
optional: true
oxlint-tsgolint@7.0.2004:
resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==}
oxlint-tsgolint@0.23.0:
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
hasBin: true
oxlint@1.88.0:
resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==}
oxlint@1.69.0:
resolution: {integrity: sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=7.0.2003'
oxlint-tsgolint: '>=0.22.1'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
@@ -9402,8 +9399,8 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tinypool@2.2.0:
resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@2.0.0:
@@ -12472,136 +12469,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.24.2':
optional: true
'@oxfmt/binding-android-arm-eabi@0.73.0':
'@oxfmt/binding-android-arm-eabi@0.54.0':
optional: true
'@oxfmt/binding-android-arm64@0.73.0':
'@oxfmt/binding-android-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.73.0':
'@oxfmt/binding-darwin-arm64@0.54.0':
optional: true
'@oxfmt/binding-darwin-x64@0.73.0':
'@oxfmt/binding-darwin-x64@0.54.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.73.0':
'@oxfmt/binding-freebsd-x64@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.73.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.73.0':
'@oxfmt/binding-linux-arm-musleabihf@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.73.0':
'@oxfmt/binding-linux-arm64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.73.0':
'@oxfmt/binding-linux-arm64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.73.0':
'@oxfmt/binding-linux-ppc64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.73.0':
'@oxfmt/binding-linux-riscv64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.73.0':
'@oxfmt/binding-linux-riscv64-musl@0.54.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.73.0':
'@oxfmt/binding-linux-s390x-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.73.0':
'@oxfmt/binding-linux-x64-gnu@0.54.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.73.0':
'@oxfmt/binding-linux-x64-musl@0.54.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.73.0':
'@oxfmt/binding-openharmony-arm64@0.54.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.73.0':
'@oxfmt/binding-win32-arm64-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.73.0':
'@oxfmt/binding-win32-ia32-msvc@0.54.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.73.0':
'@oxfmt/binding-win32-x64-msvc@0.54.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@7.0.2004':
'@oxlint-tsgolint/darwin-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/darwin-x64@7.0.2004':
'@oxlint-tsgolint/darwin-x64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-arm64@7.0.2004':
'@oxlint-tsgolint/linux-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-x64@7.0.2004':
'@oxlint-tsgolint/linux-x64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-arm64@7.0.2004':
'@oxlint-tsgolint/win32-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-x64@7.0.2004':
'@oxlint-tsgolint/win32-x64@0.23.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.88.0':
'@oxlint/binding-android-arm-eabi@1.69.0':
optional: true
'@oxlint/binding-android-arm64@1.88.0':
'@oxlint/binding-android-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-arm64@1.88.0':
'@oxlint/binding-darwin-arm64@1.69.0':
optional: true
'@oxlint/binding-darwin-x64@1.88.0':
'@oxlint/binding-darwin-x64@1.69.0':
optional: true
'@oxlint/binding-freebsd-x64@1.88.0':
'@oxlint/binding-freebsd-x64@1.69.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.88.0':
'@oxlint/binding-linux-arm-gnueabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.88.0':
'@oxlint/binding-linux-arm-musleabihf@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.88.0':
'@oxlint/binding-linux-arm64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.88.0':
'@oxlint/binding-linux-arm64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.88.0':
'@oxlint/binding-linux-ppc64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.88.0':
'@oxlint/binding-linux-riscv64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.88.0':
'@oxlint/binding-linux-riscv64-musl@1.69.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.88.0':
'@oxlint/binding-linux-s390x-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.88.0':
'@oxlint/binding-linux-x64-gnu@1.69.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.88.0':
'@oxlint/binding-linux-x64-musl@1.69.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.88.0':
'@oxlint/binding-openharmony-arm64@1.69.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.88.0':
'@oxlint/binding-win32-arm64-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.88.0':
'@oxlint/binding-win32-ia32-msvc@1.69.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.88.0':
'@oxlint/binding-win32-x64-msvc@1.69.0':
optional: true
'@parcel/watcher-android-arm64@2.5.1':
@@ -18326,61 +18323,61 @@ snapshots:
'@oxc-resolver/binding-win32-arm64-msvc': 11.24.2
'@oxc-resolver/binding-win32-x64-msvc': 11.24.2
oxfmt@0.73.0:
oxfmt@0.54.0:
dependencies:
tinypool: 2.2.0
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.73.0
'@oxfmt/binding-android-arm64': 0.73.0
'@oxfmt/binding-darwin-arm64': 0.73.0
'@oxfmt/binding-darwin-x64': 0.73.0
'@oxfmt/binding-freebsd-x64': 0.73.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.73.0
'@oxfmt/binding-linux-arm-musleabihf': 0.73.0
'@oxfmt/binding-linux-arm64-gnu': 0.73.0
'@oxfmt/binding-linux-arm64-musl': 0.73.0
'@oxfmt/binding-linux-ppc64-gnu': 0.73.0
'@oxfmt/binding-linux-riscv64-gnu': 0.73.0
'@oxfmt/binding-linux-riscv64-musl': 0.73.0
'@oxfmt/binding-linux-s390x-gnu': 0.73.0
'@oxfmt/binding-linux-x64-gnu': 0.73.0
'@oxfmt/binding-linux-x64-musl': 0.73.0
'@oxfmt/binding-openharmony-arm64': 0.73.0
'@oxfmt/binding-win32-arm64-msvc': 0.73.0
'@oxfmt/binding-win32-ia32-msvc': 0.73.0
'@oxfmt/binding-win32-x64-msvc': 0.73.0
'@oxfmt/binding-android-arm-eabi': 0.54.0
'@oxfmt/binding-android-arm64': 0.54.0
'@oxfmt/binding-darwin-arm64': 0.54.0
'@oxfmt/binding-darwin-x64': 0.54.0
'@oxfmt/binding-freebsd-x64': 0.54.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.54.0
'@oxfmt/binding-linux-arm-musleabihf': 0.54.0
'@oxfmt/binding-linux-arm64-gnu': 0.54.0
'@oxfmt/binding-linux-arm64-musl': 0.54.0
'@oxfmt/binding-linux-ppc64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-gnu': 0.54.0
'@oxfmt/binding-linux-riscv64-musl': 0.54.0
'@oxfmt/binding-linux-s390x-gnu': 0.54.0
'@oxfmt/binding-linux-x64-gnu': 0.54.0
'@oxfmt/binding-linux-x64-musl': 0.54.0
'@oxfmt/binding-openharmony-arm64': 0.54.0
'@oxfmt/binding-win32-arm64-msvc': 0.54.0
'@oxfmt/binding-win32-ia32-msvc': 0.54.0
'@oxfmt/binding-win32-x64-msvc': 0.54.0
oxlint-tsgolint@7.0.2004:
oxlint-tsgolint@0.23.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 7.0.2004
'@oxlint-tsgolint/darwin-x64': 7.0.2004
'@oxlint-tsgolint/linux-arm64': 7.0.2004
'@oxlint-tsgolint/linux-x64': 7.0.2004
'@oxlint-tsgolint/win32-arm64': 7.0.2004
'@oxlint-tsgolint/win32-x64': 7.0.2004
'@oxlint-tsgolint/darwin-arm64': 0.23.0
'@oxlint-tsgolint/darwin-x64': 0.23.0
'@oxlint-tsgolint/linux-arm64': 0.23.0
'@oxlint-tsgolint/linux-x64': 0.23.0
'@oxlint-tsgolint/win32-arm64': 0.23.0
'@oxlint-tsgolint/win32-x64': 0.23.0
oxlint@1.88.0(oxlint-tsgolint@7.0.2004):
oxlint@1.69.0(oxlint-tsgolint@0.23.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.88.0
'@oxlint/binding-android-arm64': 1.88.0
'@oxlint/binding-darwin-arm64': 1.88.0
'@oxlint/binding-darwin-x64': 1.88.0
'@oxlint/binding-freebsd-x64': 1.88.0
'@oxlint/binding-linux-arm-gnueabihf': 1.88.0
'@oxlint/binding-linux-arm-musleabihf': 1.88.0
'@oxlint/binding-linux-arm64-gnu': 1.88.0
'@oxlint/binding-linux-arm64-musl': 1.88.0
'@oxlint/binding-linux-ppc64-gnu': 1.88.0
'@oxlint/binding-linux-riscv64-gnu': 1.88.0
'@oxlint/binding-linux-riscv64-musl': 1.88.0
'@oxlint/binding-linux-s390x-gnu': 1.88.0
'@oxlint/binding-linux-x64-gnu': 1.88.0
'@oxlint/binding-linux-x64-musl': 1.88.0
'@oxlint/binding-openharmony-arm64': 1.88.0
'@oxlint/binding-win32-arm64-msvc': 1.88.0
'@oxlint/binding-win32-ia32-msvc': 1.88.0
'@oxlint/binding-win32-x64-msvc': 1.88.0
oxlint-tsgolint: 7.0.2004
'@oxlint/binding-android-arm-eabi': 1.69.0
'@oxlint/binding-android-arm64': 1.69.0
'@oxlint/binding-darwin-arm64': 1.69.0
'@oxlint/binding-darwin-x64': 1.69.0
'@oxlint/binding-freebsd-x64': 1.69.0
'@oxlint/binding-linux-arm-gnueabihf': 1.69.0
'@oxlint/binding-linux-arm-musleabihf': 1.69.0
'@oxlint/binding-linux-arm64-gnu': 1.69.0
'@oxlint/binding-linux-arm64-musl': 1.69.0
'@oxlint/binding-linux-ppc64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-gnu': 1.69.0
'@oxlint/binding-linux-riscv64-musl': 1.69.0
'@oxlint/binding-linux-s390x-gnu': 1.69.0
'@oxlint/binding-linux-x64-gnu': 1.69.0
'@oxlint/binding-linux-x64-musl': 1.69.0
'@oxlint/binding-openharmony-arm64': 1.69.0
'@oxlint/binding-win32-arm64-msvc': 1.69.0
'@oxlint/binding-win32-ia32-msvc': 1.69.0
'@oxlint/binding-win32-x64-msvc': 1.69.0
oxlint-tsgolint: 0.23.0
p-limit@2.3.0:
dependencies:
@@ -20209,7 +20206,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@2.2.0: {}
tinypool@2.1.0: {}
tinyrainbow@2.0.0: {}
@@ -20591,7 +20588,7 @@ snapshots:
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.2
vite-plugin-checker@0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.88.0(oxlint-tsgolint@7.0.2004))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(stylelint@17.15.0(typescript@5.9.3))(typescript@5.9.3):
vite-plugin-checker@0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.9.1))(stylelint@17.15.0(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@babel/code-frame': 7.29.0
chokidar: 4.0.3
@@ -20606,7 +20603,7 @@ snapshots:
eslint: 10.2.1(jiti@2.6.1)
meow: 13.2.0
optionator: 0.9.4
oxlint: 1.88.0(oxlint-tsgolint@7.0.2004)
oxlint: 1.69.0(oxlint-tsgolint@0.23.0)
stylelint: 17.15.0(typescript@5.9.3)
typescript: 5.9.3

View File

@@ -1,9 +1,16 @@
{
"channel_delete_unexp_error": "Something went wrong",
"channel_delete_success": "Channel Deleted Successfully",
"column_channel_name": "Name",
"column_channel_type": "Type",
"column_channel_action": "Action",
"column_channel_edit": "Edit",
"button_new_channel": "New Alert Channel",
"tooltip_notification_channels": "More details on how to setting notification channels",
"sending_channels_note": "The alerts will be sent to all the configured channels.",
"loading_channels_message": "Loading Channels..",
"page_title_create": "New Notification Channel",
"page_title_edit": "Edit Notification Channel",
"page_title_view": "View Notification Channel",
"button_save_channel": "Save",
"button_test_channel": "Test",
"button_return": "Back",
@@ -116,9 +123,12 @@
"help_webhook_password": "Specify a password or bearer token",
"help_pager_description": "Shows up as description in pagerduty",
"channel_creation_done": "Successfully created the channel",
"channel_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",
@@ -127,4 +137,4 @@
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}
}

View File

@@ -1,20 +1,20 @@
{
"channel_delete_unexp_error": "Something went wrong",
"channel_load_failed": "This channel could not be opened",
"channel_delete_success": "Channel Deleted Successfully",
"column_channel_name": "Name",
"column_channel_type": "Type",
"column_channel_action": "Action",
"column_channel_edit": "Edit",
"button_new_channel": "New Alert Channel",
"tooltip_notification_channels": "More details on how to setting notification channels",
"sending_channels_note": "The alerts will be sent to all the configured channels.",
"loading_channels_message": "Loading Channels..",
"page_title_create": "New Notification Channel",
"page_title_edit": "Edit Notification Channel",
"page_title_view": "View Notification Channel",
"button_save_channel": "Save",
"button_test_channel": "Test",
"button_return": "Back",
"section_general": "General",
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
"field_send_resolved": "Send resolved alerts",
"field_channel_type": "Type",
"field_webhook_url": "Webhook URL",
@@ -95,44 +95,9 @@
"placeholder_incidentio_metadata_key": "Key",
"placeholder_incidentio_metadata_value": "Value",
"button_incidentio_add_metadata": "Add metadata",
"button_incidentio_remove_metadata": "Remove metadata",
"field_slack_recipient": "Recipient",
"field_slack_title": "Title",
"field_slack_description": "Description",
"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.",
"label_slack_action_text": "Text",
"label_slack_action_url": "URL",
"label_slack_action_type": "Type",
"label_slack_action_name": "Name",
"label_slack_action_value": "Value",
"label_slack_action_style": "Style",
"label_slack_action_confirm": "Confirm",
"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",
"field_opsgenie_api_key": "API Key",
"field_opsgenie_description": "Description",
"placeholder_opsgenie_description": "Description",
@@ -144,7 +109,6 @@
"placeholder_email_html": "Email body template",
"field_webhook_username": "User Name (optional)",
"field_webhook_password": "Password (optional)",
"field_webhook_bearer_token": "Bearer token (optional)",
"field_pager_routing_key": "Routing Key",
"field_pager_description": "Description",
"field_pager_severity": "Severity",
@@ -169,23 +133,23 @@
"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_webhook_bearer_token": "Sent as an Authorization: Bearer header. Leave the username and password empty when using it.",
"help_pager_description": "Shows up as description in pagerduty",
"help_opsgenie_message": "Shows up as message in opsgenie",
"help_opsgenie_priority": "Priority of the incident",
"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",
"routing_key_required": "Routing Key is mandatory for creating pagerduty channel",
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
}
}

View File

@@ -300,17 +300,20 @@ describe('PrivateRoute', () => {
);
});
it.each([
['/logs-save-views', ROUTES.LOGS_EXPLORER],
['/traces-save-views', ROUTES.TRACES_EXPLORER],
[ROUTES.LOGS_SAVE_VIEWS, ROUTES.LOGS_EXPLORER],
[ROUTES.TRACES_SAVE_VIEWS, ROUTES.TRACES_EXPLORER],
[ROUTES.METRICS_EXPLORER_VIEWS, ROUTES.METRICS_EXPLORER_EXPLORER],
[ROUTES.METER_EXPLORER_VIEWS, ROUTES.METER_EXPLORER],
])('should redirect the old views route %s to %s', (from, to) => {
renderPrivateRoute({ initialRoute: from });
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
expect(screen.getByTestId('location-display').textContent).toBe(to);
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/saved-views',
);
});
it('should redirect /traces-save-views to /traces/saved-views', () => {
renderPrivateRoute({ initialRoute: '/traces-save-views' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/traces/saved-views',
);
});
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
@@ -1451,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: {
@@ -1462,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', () => {
@@ -1546,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'),
@@ -1636,6 +1635,11 @@ describe('PrivateRoute', () => {
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
[
'METRICS_EXPLORER_VIEWS',
ROUTES.METRICS_EXPLORER_VIEWS,
USER_ROLES.ANONYMOUS as ROLES,
],
];
it.each(authzRouteRolePairs)(

View File

@@ -513,12 +513,8 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-save-views': '/logs/logs-explorer',
'/traces-save-views': '/traces-explorer',
'/logs/saved-views': '/logs/logs-explorer',
'/traces/saved-views': '/traces-explorer',
'/metrics-explorer/views': '/metrics-explorer/explorer',
'/meter/explorer/views': '/meter/explorer',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/settings/access-tokens': '/settings/service-accounts',
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',

View File

@@ -220,7 +220,8 @@ export type ClarificationSummaryDTODiscoveredContextAnyOf = {
[key: string]: unknown;
};
export type ClarificationSummaryDTODiscoveredContext = ClarificationSummaryDTODiscoveredContextAnyOf | null;
export type ClarificationSummaryDTODiscoveredContext =
ClarificationSummaryDTODiscoveredContextAnyOf | null;
export interface ClarificationSummaryDTO {
/**
@@ -783,7 +784,8 @@ export type ThreadDetailResponseDTOActiveExecutionId = string | null;
export type ThreadDetailResponseDTOPendingApproval = ApprovalSummaryDTO | null;
export type ThreadDetailResponseDTOPendingClarification = ClarificationSummaryDTO | null;
export type ThreadDetailResponseDTOPendingClarification =
ClarificationSummaryDTO | null;
/**
* Full thread state for cold-start reconstruction (GET /threads/{id}).
@@ -973,7 +975,8 @@ export type ClarificationEventDTODiscoveredContextAnyOf = {
[key: string]: unknown;
};
export type ClarificationEventDTODiscoveredContext = ClarificationEventDTODiscoveredContextAnyOf | null;
export type ClarificationEventDTODiscoveredContext =
ClarificationEventDTODiscoveredContextAnyOf | null;
export interface ClarificationEventDTO {
/**
@@ -1296,7 +1299,8 @@ export type CreateThreadApiV1AssistantThreadsPostHeaders = {
'X-SigNoz-URL'?: string | null;
};
export type CreateThreadApiV1AssistantThreadsPostBody = CreateThreadRequestDTO | null;
export type CreateThreadApiV1AssistantThreadsPostBody =
CreateThreadRequestDTO | null;
export type ListThreadsApiV1AssistantThreadsGetParams = {
/**

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

@@ -431,7 +431,8 @@ export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelEmailConfigDTOHeaders = AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
@@ -509,7 +510,8 @@ export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
@@ -582,7 +584,8 @@ export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
@@ -703,7 +706,8 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
@@ -828,7 +832,8 @@ export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
@@ -867,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
@@ -926,8 +882,7 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO;
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
@@ -947,7 +902,6 @@ export enum AlertmanagertypesChannelKindDTO {
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
telegram = 'telegram',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
@@ -2508,7 +2462,7 @@ export interface ConfigSNSConfigDTO {
topic_arn?: string;
}
export interface AlertmanagertypesTelegramReceiverConfigDTO {
export interface ConfigTelegramConfigDTO {
api_url?: ConfigURLType2DTO;
/**
* @type integer
@@ -2540,10 +2494,6 @@ export interface AlertmanagertypesTelegramReceiverConfigDTO {
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
@@ -2749,7 +2699,7 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
telegram_configs?: ConfigTelegramConfigDTO[];
/**
* @type array
*/
@@ -2896,7 +2846,7 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
telegram_configs?: ConfigTelegramConfigDTO[];
/**
* @type array
*/
@@ -2949,9 +2899,6 @@ export interface AuthtypesAttributeMappingDTO {
role?: string;
}
/**
* The kind of authn provider.
*/
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
saml = 'saml',
}
@@ -2979,18 +2926,11 @@ export interface AuthtypesAuthDomainConfigSAMLDTO {
/**
* @type string
* @enum saml
* @description The kind of authn provider.
*/
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
/**
* @description The saml configuration.
*/
spec: AuthtypesSamlConfigDTO;
}
/**
* The kind of authn provider.
*/
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
google = 'google',
}
@@ -3039,18 +2979,11 @@ export interface AuthtypesAuthDomainConfigGoogleDTO {
/**
* @type string
* @enum google
* @description The kind of authn provider.
*/
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
/**
* @description The google auth configuration.
*/
spec: AuthtypesGoogleConfigDTO;
}
/**
* The kind of authn provider.
*/
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
oidc = 'oidc',
}
@@ -3087,12 +3020,8 @@ export interface AuthtypesAuthDomainConfigOIDCDTO {
/**
* @type string
* @enum oidc
* @description The kind of authn provider.
*/
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
/**
* @description The oidc configuration.
*/
spec: AuthtypesOIDCConfigDTO;
}
@@ -3151,7 +3080,8 @@ export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings = AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
@@ -3762,7 +3692,8 @@ export type CloudintegrationtypesAgentReportDTODataAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesAgentReportDTOData = CloudintegrationtypesAgentReportDTODataAnyOf | null;
export type CloudintegrationtypesAgentReportDTOData =
CloudintegrationtypesAgentReportDTODataAnyOf | null;
/**
* @nullable
@@ -4248,7 +4179,8 @@ export type CloudintegrationtypesPostableAgentCheckInDTODataAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesPostableAgentCheckInDTOData = CloudintegrationtypesPostableAgentCheckInDTODataAnyOf | null;
export type CloudintegrationtypesPostableAgentCheckInDTOData =
CloudintegrationtypesPostableAgentCheckInDTODataAnyOf | null;
export interface CloudintegrationtypesPostableAgentCheckInDTO {
/**
@@ -4490,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;
/**
@@ -4593,9 +4483,6 @@ export interface Querybuildertypesv5TraceAggregationDTO {
expression?: string;
}
/**
* How the upper bounds are spaced.
*/
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
linear = 'linear',
}
@@ -4615,18 +4502,11 @@ export interface Querybuildertypesv5BucketOptionsLinearDTO {
/**
* @type string
* @enum linear
* @description How the upper bounds are spaced.
*/
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
/**
* @description The evenly spaced bucket specification.
*/
spec: Querybuildertypesv5LinearBucketsSpecDTO;
}
/**
* How the upper bounds are spaced.
*/
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
log = 'log',
}
@@ -4641,12 +4521,8 @@ export interface Querybuildertypesv5BucketOptionsLogDTO {
/**
* @type string
* @enum log
* @description How the upper bounds are spaced.
*/
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
/**
* @description The logarithmic bucket specification.
*/
spec: Querybuildertypesv5LogBucketsSpecDTO;
}
@@ -4992,7 +4868,8 @@ export type DashboardtypesLegendDTOCustomColorsAnyOf = {
/**
* @nullable
*/
export type DashboardtypesLegendDTOCustomColors = DashboardtypesLegendDTOCustomColorsAnyOf | null;
export type DashboardtypesLegendDTOCustomColors =
DashboardtypesLegendDTOCustomColorsAnyOf | null;
export interface DashboardtypesLegendDTO {
/**
@@ -5584,7 +5461,8 @@ export type DashboardtypesTableFormattingDTOColumnUnitsAnyOf = {
/**
* @nullable
*/
export type DashboardtypesTableFormattingDTOColumnUnits = DashboardtypesTableFormattingDTOColumnUnitsAnyOf | null;
export type DashboardtypesTableFormattingDTOColumnUnits =
DashboardtypesTableFormattingDTOColumnUnitsAnyOf | null;
export interface DashboardtypesTableFormattingDTO {
/**
@@ -5869,40 +5747,26 @@ export type Querybuildertypesv5BuilderQuerySpecDTO =
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO
| Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTO;
/**
* The type of the query.
*/
export enum Querybuildertypesv5QueryEnvelopeBuilderDTOType {
builder_query = 'builder_query',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
/**
* @description The builder query specification.
*/
spec?: Querybuildertypesv5BuilderQuerySpecDTO;
/**
* @type string
* @enum builder_query
* @description The type of the query.
*/
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
}
/**
* The type of the query.
*/
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
builder_ai_query = 'builder_ai_query',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
/**
* @description The AI builder query specification.
*/
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
/**
* @type string
* @enum builder_ai_query
* @description The type of the query.
*/
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
}
@@ -5940,21 +5804,14 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
order?: Querybuildertypesv5OrderByDTO[] | null;
}
/**
* The type of the query.
*/
export enum Querybuildertypesv5QueryEnvelopeFormulaDTOType {
builder_formula = 'builder_formula',
}
export interface Querybuildertypesv5QueryEnvelopeFormulaDTO {
/**
* @description The formula specification.
*/
spec?: Querybuildertypesv5QueryBuilderFormulaDTO;
/**
* @type string
* @enum builder_formula
* @description The type of the query.
*/
type: Querybuildertypesv5QueryEnvelopeFormulaDTOType;
}
@@ -6017,21 +5874,14 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
stepInterval?: Querybuildertypesv5StepDTO;
}
/**
* The type of the query.
*/
export enum Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType {
builder_trace_operator = 'builder_trace_operator',
}
export interface Querybuildertypesv5QueryEnvelopeTraceOperatorDTO {
/**
* @description The trace operator specification.
*/
spec?: Querybuildertypesv5QueryBuilderTraceOperatorDTO;
/**
* @type string
* @enum builder_trace_operator
* @description The type of the query.
*/
type: Querybuildertypesv5QueryEnvelopeTraceOperatorDTOType;
}
@@ -6060,21 +5910,14 @@ export interface Querybuildertypesv5PromQueryDTO {
step?: Querybuildertypesv5StepDTO;
}
/**
* The type of the query.
*/
export enum Querybuildertypesv5QueryEnvelopePromQLDTOType {
promql = 'promql',
}
export interface Querybuildertypesv5QueryEnvelopePromQLDTO {
/**
* @description The PromQL query specification.
*/
spec?: Querybuildertypesv5PromQueryDTO;
/**
* @type string
* @enum promql
* @description The type of the query.
*/
type: Querybuildertypesv5QueryEnvelopePromQLDTOType;
}
@@ -6098,21 +5941,14 @@ export interface Querybuildertypesv5ClickHouseQueryDTO {
query?: string;
}
/**
* The type of the query.
*/
export enum Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType {
clickhouse_sql = 'clickhouse_sql',
}
export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
/**
* @description The ClickHouse SQL query specification.
*/
spec?: Querybuildertypesv5ClickHouseQueryDTO;
/**
* @type string
* @enum clickhouse_sql
* @description The type of the query.
*/
type: Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType;
}
@@ -6941,7 +6777,8 @@ export type FeaturetypesGettableFeatureDTOVariantsAnyOf = {
/**
* @nullable
*/
export type FeaturetypesGettableFeatureDTOVariants = FeaturetypesGettableFeatureDTOVariantsAnyOf | null;
export type FeaturetypesGettableFeatureDTOVariants =
FeaturetypesGettableFeatureDTOVariantsAnyOf | null;
export interface FeaturetypesGettableFeatureDTO {
/**
@@ -7419,7 +7256,8 @@ export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesClusterRecordDTOMeta = InframonitoringtypesClusterRecordDTOMetaAnyOf | null;
export type InframonitoringtypesClusterRecordDTOMeta =
InframonitoringtypesClusterRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNodeCountsByReadinessDTO {
/**
@@ -7692,7 +7530,8 @@ export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta = InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesContainerRecordDTO {
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
@@ -7783,7 +7622,8 @@ export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesDaemonSetRecordDTOMeta = InframonitoringtypesDaemonSetRecordDTOMetaAnyOf | null;
export type InframonitoringtypesDaemonSetRecordDTOMeta =
InframonitoringtypesDaemonSetRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesDaemonSetRecordDTO {
/**
@@ -7878,7 +7718,8 @@ export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesDeploymentRecordDTOMeta = InframonitoringtypesDeploymentRecordDTOMetaAnyOf | null;
export type InframonitoringtypesDeploymentRecordDTOMeta =
InframonitoringtypesDeploymentRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesDeploymentRecordDTO {
/**
@@ -7967,7 +7808,8 @@ export type InframonitoringtypesHostRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesHostRecordDTOMeta = InframonitoringtypesHostRecordDTOMetaAnyOf | null;
export type InframonitoringtypesHostRecordDTOMeta =
InframonitoringtypesHostRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesHostRecordDTO {
/**
@@ -8049,7 +7891,8 @@ export type InframonitoringtypesJobRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesJobRecordDTOMeta = InframonitoringtypesJobRecordDTOMetaAnyOf | null;
export type InframonitoringtypesJobRecordDTOMeta =
InframonitoringtypesJobRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesJobRecordDTO {
/**
@@ -8167,7 +8010,8 @@ export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesNamespaceRecordDTOMeta = InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
@@ -8234,7 +8078,8 @@ export type InframonitoringtypesNodeRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesNodeRecordDTOMeta = InframonitoringtypesNodeRecordDTOMetaAnyOf | null;
export type InframonitoringtypesNodeRecordDTOMeta =
InframonitoringtypesNodeRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNodeRecordDTO {
condition: InframonitoringtypesNodeConditionDTO;
@@ -8305,7 +8150,8 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesPodRecordDTOMeta = InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesPodRecordDTO {
/**
@@ -8692,7 +8538,8 @@ export type InframonitoringtypesStatefulSetRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesStatefulSetRecordDTOMeta = InframonitoringtypesStatefulSetRecordDTOMetaAnyOf | null;
export type InframonitoringtypesStatefulSetRecordDTOMeta =
InframonitoringtypesStatefulSetRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesStatefulSetRecordDTO {
/**
@@ -8768,7 +8615,8 @@ export type InframonitoringtypesVolumeRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesVolumeRecordDTOMeta = InframonitoringtypesVolumeRecordDTOMetaAnyOf | null;
export type InframonitoringtypesVolumeRecordDTOMeta =
InframonitoringtypesVolumeRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesVolumeRecordDTO {
/**
@@ -9886,7 +9734,8 @@ export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
/**
* @nullable
*/
export type PrometheusMatrixSeriesSchemaDTOMetric = PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
export type PrometheusMatrixSeriesSchemaDTOMetric =
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
export interface PrometheusMatrixSeriesSchemaDTO {
/**
@@ -9918,7 +9767,8 @@ export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
/**
* @nullable
*/
export type PrometheusVectorSampleSchemaDTOMetric = PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
export type PrometheusVectorSampleSchemaDTOMetric =
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
export interface PrometheusVectorSampleSchemaDTO {
/**
@@ -10328,7 +10178,8 @@ export type Querybuildertypesv5RawRowDTODataAnyOf = { [key: string]: unknown };
/**
* @nullable
*/
export type Querybuildertypesv5RawRowDTOData = Querybuildertypesv5RawRowDTODataAnyOf | null;
export type Querybuildertypesv5RawRowDTOData =
Querybuildertypesv5RawRowDTODataAnyOf | null;
export interface Querybuildertypesv5RawRowDTO {
/**
@@ -10399,7 +10250,8 @@ export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
/**
* @nullable
*/
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery = Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
/**
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
@@ -10799,9 +10651,6 @@ export interface RuletypesCumulativeWindowDTO {
timezone: string;
}
/**
* The kind of evaluation.
*/
export enum RuletypesEvaluationCumulativeDTOKind {
cumulative = 'cumulative',
}
@@ -10809,18 +10658,11 @@ export interface RuletypesEvaluationCumulativeDTO {
/**
* @type string
* @enum cumulative
* @description The kind of evaluation.
*/
kind: RuletypesEvaluationCumulativeDTOKind;
/**
* @description The cumulative window evaluation specification.
*/
spec: RuletypesCumulativeWindowDTO;
}
/**
* The kind of evaluation.
*/
export enum RuletypesEvaluationRollingDTOKind {
rolling = 'rolling',
}
@@ -10839,12 +10681,8 @@ export interface RuletypesEvaluationRollingDTO {
/**
* @type string
* @enum rolling
* @description The kind of evaluation.
*/
kind: RuletypesEvaluationRollingDTOKind;
/**
* @description The rolling window evaluation specification.
*/
spec: RuletypesRollingWindowDTO;
}
@@ -11050,28 +10888,16 @@ export enum RuletypesSeasonalityDTO {
daily = 'daily',
weekly = 'weekly',
}
/**
* The kind of threshold.
*/
export enum RuletypesThresholdBasicDTOKind {
basic = 'basic',
}
/**
* The basic threshold specification (array of thresholds).
*/
export type RuletypesThresholdBasicDTOSpec = RuletypesBasicRuleThresholdsDTO | null;
export interface RuletypesThresholdBasicDTO {
/**
* @type string
* @enum basic
* @description The kind of threshold.
*/
kind: RuletypesThresholdBasicDTOKind;
/**
* @description The basic threshold specification (array of thresholds).
*/
spec: RuletypesThresholdBasicDTOSpec;
spec: RuletypesBasicRuleThresholdsDTO | null;
}
export type RuletypesRuleThresholdDataDTO = RuletypesThresholdBasicDTO;
@@ -11734,7 +11560,8 @@ export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes = SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
@@ -11743,7 +11570,8 @@ export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource = SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
@@ -11855,7 +11683,8 @@ export type SpantypesSpanAggregationResultDTOValueAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanAggregationResultDTOValue = SpantypesSpanAggregationResultDTOValueAnyOf | null;
export type SpantypesSpanAggregationResultDTOValue =
SpantypesSpanAggregationResultDTOValueAnyOf | null;
export interface SpantypesSpanAggregationResultDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
@@ -12057,14 +11886,16 @@ export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
/**
* @nullable
*/
export type SpantypesWaterfallSpanDTOAttributes = SpantypesWaterfallSpanDTOAttributesAnyOf | null;
export type SpantypesWaterfallSpanDTOAttributes =
SpantypesWaterfallSpanDTOAttributesAnyOf | null;
export type SpantypesWaterfallSpanDTOResourceAnyOf = { [key: string]: string };
/**
* @nullable
*/
export type SpantypesWaterfallSpanDTOResource = SpantypesWaterfallSpanDTOResourceAnyOf | null;
export type SpantypesWaterfallSpanDTOResource =
SpantypesWaterfallSpanDTOResourceAnyOf | null;
export interface SpantypesWaterfallSpanDTO {
/**
@@ -12504,7 +12335,8 @@ export type TelemetrytypesGettableFieldKeysDTOKeysAnyOf = {
/**
* @nullable
*/
export type TelemetrytypesGettableFieldKeysDTOKeys = TelemetrytypesGettableFieldKeysDTOKeysAnyOf | null;
export type TelemetrytypesGettableFieldKeysDTOKeys =
TelemetrytypesGettableFieldKeysDTOKeysAnyOf | null;
export interface TelemetrytypesGettableFieldKeysDTO {
/**

View File

@@ -165,15 +165,17 @@ function createBaseSpec(
filter: getFilter(queryData),
groupBy:
queryData.groupBy?.length > 0
? queryData.groupBy.map((item: any): GroupByKey => ({
name: item.key,
fieldDataType: item?.dataType || '',
fieldContext: item?.type || '',
description: item?.description,
unit: item?.unit,
signal: item?.signal,
materialized: item?.materialized,
}))
? queryData.groupBy.map(
(item: any): GroupByKey => ({
name: item.key,
fieldDataType: item?.dataType || '',
fieldContext: item?.type || '',
description: item?.description,
unit: item?.unit,
signal: item?.signal,
materialized: item?.materialized,
}),
)
: undefined,
limit:
panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST
@@ -185,12 +187,14 @@ function createBaseSpec(
: undefined,
order:
queryData.orderBy?.length > 0
? queryData.orderBy.map((order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
}))
? queryData.orderBy.map(
(order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
}),
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
@@ -393,15 +397,17 @@ function createTraceOperatorBaseSpec(
stepInterval: stepInterval || undefined,
groupBy:
groupBy?.length > 0
? groupBy.map((item: any): GroupByKey => ({
name: item.key,
fieldDataType: item?.dataType,
fieldContext: item?.type,
description: item?.description,
unit: item?.unit,
signal: item?.signal,
materialized: item?.materialized,
}))
? groupBy.map(
(item: any): GroupByKey => ({
name: item.key,
fieldDataType: item?.dataType,
fieldContext: item?.type,
description: item?.description,
unit: item?.unit,
signal: item?.signal,
materialized: item?.materialized,
}),
)
: undefined,
limit:
panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST
@@ -410,24 +416,28 @@ function createTraceOperatorBaseSpec(
offset: requestType === 'raw' || requestType === 'trace' ? offset : undefined,
order:
orderBy?.length > 0
? orderBy.map((order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
}))
? orderBy.map(
(order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
}),
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
having: normalizeHaving(having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map((column: any): TelemetryFieldKey => ({
name: column.name ?? column.key,
fieldDataType:
column?.fieldDataType ?? (column?.dataType as FieldDataType),
fieldContext: column?.fieldContext ?? (column?.type as FieldContext),
signal: column?.signal ?? undefined,
})),
: nonEmptySelectColumns?.map(
(column: any): TelemetryFieldKey => ({
name: column.name ?? column.key,
fieldDataType:
column?.fieldDataType ?? (column?.dataType as FieldDataType),
fieldContext: column?.fieldContext ?? (column?.type as FieldContext),
signal: column?.signal ?? undefined,
}),
),
};
}
@@ -600,12 +610,14 @@ export const prepareQueryRangePayloadV5 = (
disabled: formulaData.disabled,
limit: formulaData.limit ?? undefined,
legend: isEmpty(formulaData.legend) ? undefined : formulaData.legend,
order: formulaData.orderBy?.map((order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
})),
order: formulaData.orderBy?.map(
(order: any): OrderBy => ({
key: {
name: order.columnName,
},
direction: order.order,
}),
),
},
}),
);

View File

@@ -1,5 +1,5 @@
.breadcrumb {
padding: var(--spacing-4) 16px var(--spacing-2);
padding-left: 16px;
ol {
align-items: center;

View File

@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
@@ -11,7 +10,7 @@ export type BreadcrumbItemConfig =
route?: string;
}
| {
title: ReactNode;
title: string | null;
isLast?: true;
};

View File

@@ -9,8 +9,3 @@
margin-inline-end: 12px;
flex-shrink: 0;
}
.name {
color: var(--danger-background);
font-weight: var(--font-weight-medium);
}

View File

@@ -111,11 +111,14 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
const lineChartRef = useRef<Chart>();
useImperativeHandle(ref, (): ToggleGraphProps => ({
toggleGraph(graphIndex: number, isVisible: boolean): void {
toggleGraph(graphIndex, isVisible, lineChartRef);
},
}));
useImperativeHandle(
ref,
(): ToggleGraphProps => ({
toggleGraph(graphIndex: number, isVisible: boolean): void {
toggleGraph(graphIndex, isVisible, lineChartRef);
},
}),
);
const getGridColor = useCallback(() => {
if (currentTheme === undefined) {

View File

@@ -175,19 +175,21 @@ function OptionsMenu({
<div className="title">FORMAT</div>
<div className="menu-items">
{items.map((item: any): JSX.Element => (
<div
className="item"
key={item.label}
onClick={(): void => handleMenuItemClick(item.key)}
>
<div className={cx('item-label')}>
{item.label}
{items.map(
(item: any): JSX.Element => (
<div
className="item"
key={item.label}
onClick={(): void => handleMenuItemClick(item.key)}
>
<div className={cx('item-label')}>
{item.label}
{selectedItem === item.key && <Check size={12} />}
{selectedItem === item.key && <Check size={12} />}
</div>
</div>
</div>
))}
),
)}
</div>
</div>

View File

@@ -11,8 +11,13 @@
border-top: 1px solid var(--l1-border);
font-family:
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', sans-serif;
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
sans-serif;
border-right: none;
border-left: none;

View File

@@ -12,8 +12,13 @@ $max-recents-shown: 5;
flex-direction: column;
gap: 8px;
font-family:
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', sans-serif;
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
sans-serif;
.query-where-clause-editor-container {
position: relative;
@@ -133,7 +138,8 @@ $max-recents-shown: 5;
// least N recents visible.
@for $i from 1 through $max-recents-shown {
&:has(> li:nth-of-type(#{$i}) .cm-completionIcon-recent) {
max-height: $dropdown-base-height + $recents-header-height +
max-height: $dropdown-base-height +
$recents-header-height +
($i * $recent-row-height) !important;
}
}

View File

@@ -13,22 +13,26 @@ function LabelColumn({ labels, value }: LabelColumnProps): JSX.Element {
return (
<div className="label-column">
{newLabels.map((label: string): JSX.Element => (
<BadgeWithTooltip key={label} label={label} value={value} />
))}
{newLabels.map(
(label: string): JSX.Element => (
<BadgeWithTooltip key={label} label={label} value={value} />
),
)}
{remainingLabels.length > 0 && (
<Popover
placement="bottomRight"
showArrow={false}
content={
<div>
{labels.map((label: string): JSX.Element => (
<div key={label}>
<Badge textTransform="none" variant="solid" color="secondary">
{getLabelAndValueContent(label, value && value[label])}
</Badge>
</div>
))}
{labels.map(
(label: string): JSX.Element => (
<div key={label}>
<Badge textTransform="none" variant="solid" color="secondary">
{getLabelAndValueContent(label, value && value[label])}
</Badge>
</div>
),
)}
</div>
}
trigger="hover"

View File

@@ -50,11 +50,14 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
const onCreateRef = useRef(onCreate);
const onDeleteRef = useRef(onDelete);
useImperativeHandle(ref, (): ToggleGraphProps => ({
toggleGraph(graphIndex: number, isVisible: boolean): void {
chartRef.current?.setSeries(graphIndex, { show: isVisible });
},
}));
useImperativeHandle(
ref,
(): ToggleGraphProps => ({
toggleGraph(graphIndex: number, isVisible: boolean): void {
chartRef.current?.setSeries(graphIndex, { show: isVisible });
},
}),
);
useEffect(() => {
onCreateRef.current = onCreate;

View File

@@ -511,7 +511,7 @@ export const QUERY_BUILDER_OPERATORS_BY_TYPES = {
};
export enum OperatorConfigKeys {
EXCEPTIONS = 'EXCEPTIONS',
'EXCEPTIONS' = 'EXCEPTIONS',
}
export const OPERATORS_CONFIG = {

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

@@ -13,19 +13,20 @@ jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(({ setSelectedEndPointName }): JSX.Element => (
default: jest.fn().mockImplementation(
({ setSelectedEndPointName }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => setSelectedEndPointName(e.target.value)}
role="combobox"
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
),
),
}),
);

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,147 +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',
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,247 +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 { omit, pick } from 'lodash-es';
import { omitBlank } from 'utils/omitBlank';
import { ChannelFormValues, ChannelKind, ChannelSpecFormValues } from './types';
import { canonicalJiraReopenDuration, filledRows } from './utils';
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',
'source',
'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]: [
'apiUrl',
'botToken',
'chatId',
'messageThreadId',
'title',
'message',
'disableNotifications',
'sendResolved',
] as const satisfies readonly (keyof AlertmanagertypesChannelTelegramConfigDTO)[],
} satisfies Record<ChannelKind, readonly (keyof ChannelSpecFormValues)[]>;
/**
* `satisfies` above only proves each listed key exists on the DTO, so a field
* left out still compiles and is then dropped on save. This fails the build
* instead, and the error names the missing field.
*/
type MissingFrom<D, T extends readonly PropertyKey[]> = Exclude<
keyof D,
T[number]
>;
type SpecFieldsComplete = {
slack: MissingFrom<
AlertmanagertypesChannelSlackConfigDTO,
(typeof SPEC_FIELDS)['slack']
>;
webhook: MissingFrom<
AlertmanagertypesChannelWebhookConfigDTO,
(typeof SPEC_FIELDS)['webhook']
>;
email: MissingFrom<
AlertmanagertypesChannelEmailConfigDTO,
(typeof SPEC_FIELDS)['email']
>;
pagerduty: MissingFrom<
AlertmanagertypesChannelPagerdutyConfigDTO,
(typeof SPEC_FIELDS)['pagerduty']
>;
opsgenie: MissingFrom<
AlertmanagertypesChannelOpsgenieConfigDTO,
(typeof SPEC_FIELDS)['opsgenie']
>;
msteams: MissingFrom<
AlertmanagertypesChannelMSTeamsConfigDTO,
(typeof SPEC_FIELDS)['msteams']
>;
googlechat: MissingFrom<
AlertmanagertypesChannelGoogleChatConfigDTO,
(typeof SPEC_FIELDS)['googlechat']
>;
jira: MissingFrom<
AlertmanagertypesChannelJiraConfigDTO,
(typeof SPEC_FIELDS)['jira']
>;
jsmops: MissingFrom<
AlertmanagertypesChannelJSMOpsConfigDTO,
(typeof SPEC_FIELDS)['jsmops']
>;
incidentio: MissingFrom<
AlertmanagertypesChannelIncidentIOConfigDTO,
(typeof SPEC_FIELDS)['incidentio']
>;
telegram: MissingFrom<
AlertmanagertypesChannelTelegramConfigDTO,
(typeof SPEC_FIELDS)['telegram']
>;
};
// every member must be `never`; a missed field shows up here by name
type NothingMissing<T extends Record<keyof T, never>> = T;
export type SpecFieldsAreExhaustive = NothingMissing<SpecFieldsComplete>;
export function toChannelConfig(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesChannelConfigDTO {
const picked: Record<string, unknown> = pick(values, SPEC_FIELDS[kind]);
if (kind === ChannelKind.slack) {
picked.fields = filledRows(
picked.fields as AlertmanagertypesChannelSlackConfigDTO['fields'],
'title',
);
picked.actions = filledRows(
picked.actions as AlertmanagertypesChannelSlackConfigDTO['actions'],
'text',
).map((action) =>
action.confirm?.text?.trim() ? action : omit(action, 'confirm'),
);
}
const spec: Record<string, unknown> = omitBlank(picked);
// 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);
}
return { kind, spec } as unknown as AlertmanagertypesChannelConfigDTO;
}
export function toPostableChannel(
kind: ChannelKind,
values: ChannelFormValues,
): AlertmanagertypesPostableNotificationChannelDTO {
return {
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,32 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelFormValues, ChannelKind } from './types';
export interface ChannelFormState {
kind: ChannelKind;
values: ChannelFormValues;
}
/** Each union member types `kind` as its own enum, so a plain cast is unsafe. */
function toChannelKind(kind: string): ChannelKind {
const known = (Object.values(ChannelKind) as string[]).includes(kind);
if (!known) {
throw new Error(`This build cannot render a channel of kind "${kind}".`);
}
return kind as ChannelKind;
}
/** The form edits the spec the API returns, so loading lifts it whole. */
export function toChannelFormState(
channel: AlertmanagertypesGettableNotificationChannelDTO,
): ChannelFormState {
return {
kind: toChannelKind(channel.config.kind),
values: {
...channel.config.spec,
name: channel.displayName,
},
};
}

View File

@@ -1,39 +0,0 @@
import { TFunction } from 'i18next';
import type { FieldErrors, Resolver } from 'react-hook-form';
import type { $ZodIssue } from 'zod/v4/core';
import { channelNameSchema } from './schemas/shared';
import { ChannelFormValues, ChannelKind } from './types';
import { CHANNEL_SCHEMAS } from './validation';
const toFieldErrors = (
issues: $ZodIssue[],
t: TFunction,
): FieldErrors<ChannelFormValues> => {
const errors: Record<string, { type: string; message: string }> = {};
issues.forEach((issue) => {
const field = issue.path.join('.') || 'name';
if (!errors[field]) {
errors[field] = { type: 'validation', message: t(issue.message) };
}
});
return errors as FieldErrors<ChannelFormValues>;
};
export const channelResolver =
(kind: ChannelKind, t: TFunction): Resolver<ChannelFormValues> =>
async (values) => {
const schema = CHANNEL_SCHEMAS[kind];
const issues: $ZodIssue[] = [
...(channelNameSchema.safeParse(values).error?.issues ?? []),
...(schema ? (schema.safeParse(values).error?.issues ?? []) : []),
];
if (issues.length === 0) {
return { values, errors: {} };
}
return { values: {}, errors: toFieldErrors(issues, t) };
};

View File

@@ -1,132 +0,0 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import { toChannelConfig, toUpdatableChannel } from './channelConfig';
import { toChannelFormState } from './channelFormValues';
import { ChannelKind } from './types';
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',
source: 'signoz',
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',
reopenDuration: '3d',
customFields: { customfield_1: 'v' },
sendResolved: true,
},
jsmops: {
apiKey: 'k',
message: 'm',
description: 'd',
priority: 'P2',
tags: 'prod,db',
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,30 +562,33 @@ export const EmailInitialConfig: Partial<AlertmanagertypesChannelEmailConfigDTO>
</table>
</body>
</html>`,
};
};
// 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
// `message` is left unset so the backend applies its own template; an empty
// string would overwrite it.
export const TelegramInitialConfig: Partial<AlertmanagertypesChannelTelegramConfigDTO> =
{
sendResolved: true,
};
export const DEFAULT_CHANNEL_KIND = ChannelKind.slack;
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,7 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const emailSchema = z.object({
to: requiredString('to_required'),
});

View File

@@ -1,11 +0,0 @@
import { z } from 'zod';
import { isValidGoogleChatWebhookURL } from '../utils';
import { requiredString } from './shared';
export const googleChatSchema = z.object({
webhookUrl: requiredString('webhook_url_required').refine(
isValidGoogleChatWebhookURL,
{ message: 'google_chat_webhook_url_invalid' },
),
});

View File

@@ -1,13 +0,0 @@
import { z } from 'zod';
import { isValidIncidentIOURL } from '../utils';
import { requiredString } from './shared';
const required = requiredString('incidentio_required_fields');
export const incidentIOSchema = z.object({
url: required.refine(isValidIncidentIOURL, {
message: 'incidentio_url_invalid',
}),
token: required,
});

View File

@@ -1,20 +0,0 @@
import { z } from 'zod';
import { isValidJiraReopenDuration, isValidJiraSiteURL } from '../utils';
import { requiredString } from './shared';
const required = requiredString('jira_required_fields');
export const jiraSchema = z.object({
site: required.refine(isValidJiraSiteURL, { message: 'jira_site_invalid' }),
email: required,
apiToken: required,
project: required,
issueType: required,
reopenDuration: z
.string()
.optional()
.refine((v) => !v || isValidJiraReopenDuration(v), {
message: 'jira_reopen_duration_invalid',
}),
});

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const jsmOpsSchema = z.object({
apiKey: requiredString('api_key_required'),
});

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const msTeamsSchema = z.object({
webhookUrl: requiredString('webhook_url_required'),
});

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const opsgenieSchema = z.object({
apiKey: requiredString('api_key_required'),
});

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const pagerdutySchema = z.object({
routingKey: requiredString('routing_key_required'),
});

View File

@@ -1,10 +0,0 @@
import { z } from 'zod';
export const requiredString = (messageKey: string): z.ZodString =>
// an untouched field arrives as undefined, which would otherwise report zod's
// own "expected string" instead of the message the form should show
z.string({ error: messageKey }).trim().min(1, messageKey);
export const channelNameSchema = z.object({
name: requiredString('channel_name_required'),
});

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const slackSchema = z.object({
apiUrl: requiredString('webhook_url_required'),
});

View File

@@ -1,11 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const telegramSchema = z.object({
botToken: requiredString('bot_token_required'),
// the backend reads 0 as missing, and omitBlank deliberately keeps 0
chatId: z
.number({ error: 'chat_id_required' })
.refine((value) => value !== 0, { error: 'chat_id_required' }),
});

View File

@@ -1,16 +0,0 @@
import { z } from 'zod';
import { requiredString } from './shared';
export const webhookSchema = z
.object({
url: requiredString('webhook_url_required'),
username: z.string().optional(),
password: z.string().optional(),
})
// the API allows bearer-only and no-auth webhooks, but a username without its
// password is still an incomplete basic auth pair
.refine((v) => !v.username || !!v.password, {
message: 'username_no_password',
path: ['password'],
});

View File

@@ -1,36 +0,0 @@
import {
AlertmanagertypesChannelEmailConfigDTO,
AlertmanagertypesChannelGoogleChatConfigDTO,
AlertmanagertypesChannelIncidentIOConfigDTO,
AlertmanagertypesChannelJiraConfigDTO,
AlertmanagertypesChannelJSMOpsConfigDTO,
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelMSTeamsConfigDTO,
AlertmanagertypesChannelOpsgenieConfigDTO,
AlertmanagertypesChannelPagerdutyConfigDTO,
AlertmanagertypesChannelSlackConfigDTO,
AlertmanagertypesChannelTelegramConfigDTO,
AlertmanagertypesChannelWebhookConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
export const ChannelKind = AlertmanagertypesChannelKindDTO;
export type ChannelKind = AlertmanagertypesChannelKindDTO;
/** Merged so switching kind keeps the fields the kinds share. */
export type ChannelSpecFormValues = Partial<
AlertmanagertypesChannelSlackConfigDTO &
AlertmanagertypesChannelWebhookConfigDTO &
AlertmanagertypesChannelEmailConfigDTO &
AlertmanagertypesChannelPagerdutyConfigDTO &
AlertmanagertypesChannelOpsgenieConfigDTO &
AlertmanagertypesChannelMSTeamsConfigDTO &
AlertmanagertypesChannelGoogleChatConfigDTO &
AlertmanagertypesChannelJiraConfigDTO &
AlertmanagertypesChannelJSMOpsConfigDTO &
AlertmanagertypesChannelIncidentIOConfigDTO &
AlertmanagertypesChannelTelegramConfigDTO
>;
export type ChannelFormValues = ChannelSpecFormValues & {
name?: string;
};

View File

@@ -1,5 +1,26 @@
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
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
@@ -11,8 +32,25 @@ 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
export const isValidJiraSiteURL = (url: string): boolean => {
try {
const { protocol, hostname } = new URL(url);
@@ -39,6 +77,8 @@ const JIRA_DURATION_RE = /^(\d+(ms|s|m|h|d|w|y))+$/;
const JIRA_DURATION_TOKEN_RE = /(\d+)(ms|s|m|h|d|w|y)/g;
const JIRA_MIN_REOPEN_MS = 60_000;
// backend requires the same format and a >= 1m minimum, this is only for a
// nicer error experience. Empty and "0" defer to the backend default.
export const isValidJiraReopenDuration = (value: string): boolean => {
if (!value || value === '0') {
return true;
@@ -53,45 +93,85 @@ export const isValidJiraReopenDuration = (value: string): boolean => {
return totalMs >= JIRA_MIN_REOPEN_MS;
};
/**
* How prometheus `model.Duration` prints itself, the only spelling the API takes
* back: "72h" becomes "3d", "7d" becomes "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. */
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/';
@@ -111,8 +191,32 @@ export const isValidIncidentIOURL = (url: string): boolean => {
}
};
export const filledRows = <T>(
rows: T[] | null | undefined,
required: keyof T,
): T[] =>
(rows ?? []).filter((row) => String(row[required] ?? '').trim() !== '');
// 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,49 +0,0 @@
import { TFunction } from 'i18next';
import type { ZodType } from 'zod';
import { emailSchema } from './schemas/email';
import { googleChatSchema } from './schemas/googlechat';
import { incidentIOSchema } from './schemas/incidentio';
import { jiraSchema } from './schemas/jira';
import { jsmOpsSchema } from './schemas/jsmops';
import { msTeamsSchema } from './schemas/msteams';
import { opsgenieSchema } from './schemas/opsgenie';
import { pagerdutySchema } from './schemas/pagerduty';
import { channelNameSchema } from './schemas/shared';
import { slackSchema } from './schemas/slack';
import { telegramSchema } from './schemas/telegram';
import { webhookSchema } from './schemas/webhook';
import { ChannelFormValues, ChannelKind } from './types';
export const CHANNEL_SCHEMAS: Record<ChannelKind, ZodType> = {
[ChannelKind.slack]: slackSchema,
[ChannelKind.webhook]: webhookSchema,
[ChannelKind.pagerduty]: pagerdutySchema,
[ChannelKind.opsgenie]: opsgenieSchema,
[ChannelKind.jsmops]: jsmOpsSchema,
[ChannelKind.email]: emailSchema,
[ChannelKind.msteams]: msTeamsSchema,
[ChannelKind.googlechat]: googleChatSchema,
[ChannelKind.jira]: jiraSchema,
[ChannelKind.incidentio]: incidentIOSchema,
[ChannelKind.telegram]: telegramSchema,
};
export function validateChannel(
kind: ChannelKind,
values: ChannelFormValues,
t: TFunction,
): string | null {
const nameResult = channelNameSchema.safeParse(values);
if (!nameResult.success) {
return t(nameResult.error.issues[0].message);
}
const schema = CHANNEL_SCHEMAS[kind];
if (!schema) {
return t('selected_channel_invalid');
}
const result = schema.safeParse(values);
return result.success ? null : t(result.error.issues[0].message);
}

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

@@ -16,19 +16,19 @@ export interface IAdvancedOptionItemProps {
}
export enum RollingWindowTimeframes {
LAST_5_MINUTES = '5m0s',
LAST_10_MINUTES = '10m0s',
LAST_15_MINUTES = '15m0s',
LAST_30_MINUTES = '30m0s',
LAST_1_HOUR = '1h0m0s',
LAST_2_HOURS = '2h0m0s',
LAST_4_HOURS = '4h0m0s',
'LAST_5_MINUTES' = '5m0s',
'LAST_10_MINUTES' = '10m0s',
'LAST_15_MINUTES' = '15m0s',
'LAST_30_MINUTES' = '30m0s',
'LAST_1_HOUR' = '1h0m0s',
'LAST_2_HOURS' = '2h0m0s',
'LAST_4_HOURS' = '4h0m0s',
}
export enum CumulativeWindowTimeframes {
CURRENT_HOUR = 'currentHour',
CURRENT_DAY = 'currentDay',
CURRENT_MONTH = 'currentMonth',
'CURRENT_HOUR' = 'currentHour',
'CURRENT_DAY' = 'currentDay',
'CURRENT_MONTH' = 'currentMonth',
}
export interface IEvaluationWindowPopoverProps {

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

@@ -1,38 +0,0 @@
.form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.cards {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-bottom: var(--spacing-6);
border: 0;
margin: 0;
min-width: 0;
}
.fields {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l1-border);
}
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
.fields :global(textarea:disabled) {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,23 +0,0 @@
.card {
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.head {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-4) var(--spacing-5) 0;
}
.title {
font-weight: var(--font-weight-medium);
}
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-5) var(--spacing-5);
}

View File

@@ -1,35 +0,0 @@
import { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import styles from './FormCard.module.scss';
interface FormCardProps {
title: string;
/** One line on what the group is for, where the title alone is not enough. */
description?: string;
children: ReactNode;
}
function FormCard({
title,
description,
children,
}: FormCardProps): JSX.Element {
return (
<section className={styles.card}>
<header className={styles.head}>
<Typography.Text className={styles.title}>{title}</Typography.Text>
{!!description && (
<Typography.Text size="sm" color="muted">
{description}
</Typography.Text>
)}
</header>
<div className={styles.body}>{children}</div>
</section>
);
}
FormCard.defaultProps = { description: undefined };
export default FormCard;

View File

@@ -1,31 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
}
.label {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.hint {
color: var(--muted-foreground);
cursor: help;
flex-shrink: 0;
}
.required {
color: var(--danger-background);
line-height: 1;
}
/* The design system's placeholder colour is close enough to the value colour
that an empty field reads as a filled one. */
.field input::placeholder,
.field textarea::placeholder {
color: var(--muted-foreground);
opacity: 0.65;
}

View File

@@ -1,61 +0,0 @@
import { ReactNode } from 'react';
import { CircleHelp } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import styles from './FormField.module.scss';
interface FormFieldProps {
label?: ReactNode;
help?: ReactNode;
tooltip?: string;
required?: boolean;
error?: string;
htmlFor?: string;
children: ReactNode;
}
function FormField({
label,
help,
tooltip,
required,
error,
htmlFor,
children,
}: FormFieldProps): JSX.Element {
const hint = tooltip ? (
<MarkdownRenderer markdownContent={tooltip} variables={{}} />
) : (
help
);
return (
<div className={styles.field}>
{!!label && (
<label className={styles.label} htmlFor={htmlFor}>
<Typography.Text size="sm">{label}</Typography.Text>
{!!required && (
<span aria-hidden="true" className={styles.required}>
*
</span>
)}
{!!hint && (
<Tooltip title={hint}>
<CircleHelp size={12} className={styles.hint} />
</Tooltip>
)}
</label>
)}
{children}
{!!error && (
<Typography.Text size="sm" color="danger">
{error}
</Typography.Text>
)}
</div>
);
}
export default FormField;

View File

@@ -1,37 +1,49 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import { Form } from 'antd';
import { EmailChannel } from '../../CreateAlertChannels/config';
function EmailForm(): JSX.Element {
function EmailForm({ setSelectedConfig }: EmailFormProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const handleInputChange =
(field: string) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
setSelectedConfig((value) => ({
...value,
[field]: event.target.value,
}));
};
return (
<>
<FormCard title={t('section_recipients')}>
<FormField
help={t('help_email_to')}
label={t('field_email_to')}
tooltip={t('tooltip_email_to')}
error={errors.to?.message}
required
>
<Input
{...register('to')}
aria-required
placeholder={t('placeholder_email_to')}
data-testid="email-to-textbox"
/>
</FormField>
</FormCard>
<Form.Item
name="to"
help={t('help_email_to')}
label={t('field_email_to')}
required
>
<Input
onChange={handleInputChange('to')}
placeholder={t('placeholder_email_to')}
data-testid="email-to-textbox"
/>
</Form.Item>
{/* <Form.Item name="html" label={t('field_email_html')} required>
<TextArea
rows={4}
onChange={handleInputChange('html')}
placeholder={t('placeholder_email_html')}
/>
</Form.Item> */}
</>
);
}
interface EmailFormProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<EmailChannel>>>;
}
export default EmailForm;

View File

@@ -1,74 +1,82 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import { Form, Input } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
const { TextArea } = AntdInput;
function GoogleChat(): JSX.Element {
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<FormCard title={t('section_connection')}>
<FormField
label={t('field_webhook_url')}
tooltip={t('tooltip_google_chat_url')}
error={errors.webhookUrl?.message}
required
>
<Input
{...register('webhookUrl')}
aria-required
data-testid="webhook-url-textbox"
/>
</FormField>
<Form.Item
name="webhook_url"
label={t('field_webhook_url')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidGoogleChatWebhookURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('google_chat_webhook_url_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_google_chat_url')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
onChange={(event): void => {
setSelectedConfig((value) => ({
...value,
webhook_url: event.target.value,
}));
}}
data-testid="webhook-url-textbox"
/>
</Form.Item>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
data-testid="title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<Form.Item name="title" label={t('field_slack_title')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
title: event.target.value,
}))
}
data-testid="title-textarea"
/>
</Form.Item>
<FormField
label={t('field_slack_description')}
error={errors.text?.message}
>
<Controller
control={control}
name="text"
render={({ field }): JSX.Element => (
<TextArea
rows={4}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<Form.Item name="text" label={t('field_slack_description')}>
<Input.TextArea
rows={4}
onChange={(event): void =>
setSelectedConfig((value) => ({
...value,
text: event.target.value,
}))
}
data-testid="description-textarea"
placeholder={t('placeholder_slack_description')}
/>
</Form.Item>
</>
);
}
interface GoogleChatProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
}
export default GoogleChat;

View File

@@ -1,33 +1,21 @@
import { useState } from 'react';
import { Dispatch, SetStateAction, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Minus, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button } from '@signozhq/ui/button';
import { Input as AntdInput } from 'antd';
import { Button, Form, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import styles from './RowEditor.module.scss';
const { TextArea } = AntdInput;
import { IncidentIOChannel } from '../../CreateAlertChannels/config';
interface MetadataRow {
key: string;
value: string;
}
function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
function IncidentIOSettings({
setSelectedConfig,
initialMetadata,
}: IncidentIOProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
setValue,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const [metadataRows, setMetadataRows] = useState<MetadataRow[]>(() =>
Object.entries(initialMetadata || {}).map(([key, value]) => ({
key,
@@ -35,177 +23,145 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
})),
);
// The API models metadata as a map, so the rows are held here rather than in
// the form: an object loses their order and drops a key still being typed.
const update = (patch: Partial<IncidentIOChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const syncMetadata = (rows: MetadataRow[]): void => {
setMetadataRows(rows);
setValue(
'metadata',
Object.fromEntries(
update({
metadata: Object.fromEntries(
rows
.filter((row) => row.key.trim() !== '')
.map((row) => [row.key, row.value]),
),
{ shouldDirty: true },
);
});
};
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="incidentio-tip"
style={{ display: 'block', marginBottom: 16 }}
<Typography.Text
color="muted"
size="sm"
testId="incidentio-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('incidentio_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/incidentio/"
target="_blank"
rel="noopener noreferrer"
>
{t('incidentio_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/incidentio/"
target="_blank"
rel="noopener noreferrer"
>
{t('incidentio_tip_link')}
</Typography.Link>
</Typography.Text>
{t('incidentio_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_incidentio_url')}
help={t('help_incidentio_url')}
error={errors.url?.message}
required
>
<Input
{...register('url')}
aria-required
data-testid="incidentio-url-textbox"
/>
</FormField>
<Form.Item
name="url"
label={t('field_incidentio_url')}
help={t('help_incidentio_url')}
required
>
<Input
onChange={(event): void => update({ url: event.target.value })}
data-testid="incidentio-url-textbox"
/>
</Form.Item>
<FormField
label={t('field_incidentio_token')}
help={t('help_incidentio_token')}
error={errors.token?.message}
required
>
<Input
{...register('token')}
aria-required
type="password"
data-testid="incidentio-token-textbox"
/>
</FormField>
<Form.Item
name="token"
label={t('field_incidentio_token')}
help={t('help_incidentio_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ token: event.target.value })}
data-testid="incidentio-token-textbox"
/>
</Form.Item>
<FormField
label={t('field_incidentio_title')}
help={t('help_incidentio_title')}
error={errors.title?.message}
>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="incidentio-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<Form.Item
name="title"
label={t('field_incidentio_title')}
help={t('help_incidentio_title')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ title: event.target.value })}
data-testid="incidentio-title-textarea"
/>
</Form.Item>
<FormField
label={t('field_incidentio_description')}
help={t('help_incidentio_description')}
error={errors.description?.message}
>
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="incidentio-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<Form.Item
name="description"
label={t('field_incidentio_description')}
help={t('help_incidentio_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="incidentio-description-textarea"
/>
</Form.Item>
<FormField
label={t('field_incidentio_metadata')}
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.row}>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
onChange={(event): void =>
syncMetadata(
metadataRows.map((r, i) =>
i === index ? { ...r, key: event.target.value } : r,
),
)
}
data-testid={`incidentio-metadata-key-${index}`}
/>
</div>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
onChange={(event): void =>
syncMetadata(
metadataRows.map((r, i) =>
i === index ? { ...r, value: event.target.value } : r,
),
)
}
data-testid={`incidentio-metadata-value-${index}`}
/>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('button_incidentio_remove_metadata')}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
testId={`incidentio-metadata-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
<div className={styles.add}>
<Button
variant="dashed"
color="secondary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void =>
syncMetadata([...metadataRows, { key: '', value: '' }])
<Form.Item
label={t('field_incidentio_metadata')}
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
onChange={(event): void =>
syncMetadata(
metadataRows.map((r, i) =>
i === index ? { ...r, key: event.target.value } : r,
),
)
}
testId="incidentio-metadata-add"
>
{t('button_incidentio_add_metadata')}
</Button>
data-testid={`incidentio-metadata-key-${index}`}
/>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
onChange={(event): void =>
syncMetadata(
metadataRows.map((r, i) =>
i === index ? { ...r, value: event.target.value } : r,
),
)
}
data-testid={`incidentio-metadata-value-${index}`}
/>
<Button
icon={<Minus size={14} />}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
data-testid={`incidentio-metadata-remove-${index}`}
/>
</div>
</FormField>
</FormCard>
))}
<Button
type="dashed"
icon={<Plus size={14} />}
onClick={(): void =>
syncMetadata([...metadataRows, { key: '', value: '' }])
}
data-testid="incidentio-metadata-add"
>
{t('button_incidentio_add_metadata')}
</Button>
</Form.Item>
</>
);
}
interface IncidentIOProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<IncidentIOChannel>>>;
initialMetadata?: Record<string, string>;
}

View File

@@ -1,229 +1,256 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput, Select } from 'antd';
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 FormCard from '../FormCard';
import FormField from '../FormField';
import { JiraChannel } from '../../CreateAlertChannels/config';
import {
isValidJiraReopenDuration,
isValidJiraSiteURL,
} from '../../CreateAlertChannels/utils';
const { TextArea } = AntdInput;
function JiraSettings(): JSX.Element {
function JiraSettings({ setSelectedConfig }: JiraProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const update = (patch: Partial<JiraChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
<>
<FormField
<Form.Item
name="priority"
label={t('field_jira_priority')}
help={t('help_jira_priority')}
error={errors.priority?.message}
>
<Input
{...register('priority')}
placeholder={t('placeholder_jira_priority')}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jira-priority-textbox"
/>
</FormField>
</Form.Item>
<FormField
<Form.Item
name="labels"
label={t('field_jira_labels')}
help={t('help_jira_labels')}
error={errors.labels?.message}
>
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jira_labels')}
onChange={(value): void => update({ labels: value as string[] })}
data-testid="jira-labels-select"
/>
</FormField>
</Form.Item>
<FormField
<Form.Item
name="resolve_transition"
label={t('field_jira_resolve_transition')}
help={t('help_jira_resolve_transition')}
error={errors.resolveTransition?.message}
>
<Input
{...register('resolveTransition')}
placeholder={t('placeholder_jira_resolve_transition')}
onChange={(event): void =>
update({ resolve_transition: event.target.value })
}
data-testid="jira-resolve-transition-textbox"
/>
</FormField>
</Form.Item>
<FormField
<Form.Item
name="reopen_transition"
label={t('field_jira_reopen_transition')}
help={t('help_jira_reopen_transition')}
error={errors.reopenTransition?.message}
>
<Input
{...register('reopenTransition')}
placeholder={t('placeholder_jira_reopen_transition')}
onChange={(event): void =>
update({ reopen_transition: event.target.value })
}
data-testid="jira-reopen-transition-textbox"
/>
</FormField>
</Form.Item>
<FormField
<Form.Item
name="wont_fix_resolution"
label={t('field_jira_wont_fix_resolution')}
help={t('help_jira_wont_fix_resolution')}
error={errors.wontFixResolution?.message}
>
<Input
{...register('wontFixResolution')}
placeholder={t('placeholder_jira_wont_fix_resolution')}
onChange={(event): void =>
update({ wont_fix_resolution: event.target.value })
}
data-testid="jira-wont-fix-resolution-textbox"
/>
</FormField>
</Form.Item>
<FormField
<Form.Item
name="reopen_duration"
label={t('field_jira_reopen_duration')}
tooltip={t('tooltip_jira_reopen_duration')}
help={t('help_jira_reopen_duration')}
error={errors.reopenDuration?.message}
extra={t('help_jira_reopen_duration')}
rules={[
{
validator: (_, value: string): Promise<void> =>
isValidJiraReopenDuration(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_reopen_duration_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_reopen_duration')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
{...register('reopenDuration')}
placeholder={t('placeholder_jira_reopen_duration')}
onChange={(event): void => update({ reopen_duration: event.target.value })}
data-testid="jira-reopen-duration-textbox"
/>
</FormField>
</Form.Item>
</>
);
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="jira-service-account-tip"
style={{ display: 'block', marginBottom: 16 }}
<Typography.Text
color="muted"
size="sm"
testId="jira-service-account-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jira_service_account_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_service_account_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jira/#use-a-service-account-recommended"
target="_blank"
rel="noopener noreferrer"
>
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
{t('jira_service_account_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_jira_site')}
tooltip={t('tooltip_jira_site')}
error={errors.site?.message}
required
>
<Input
{...register('site')}
aria-required
placeholder="https://your-domain.atlassian.net"
data-testid="jira-site-textbox"
/>
</FormField>
<Form.Item
name="site"
label={t('field_jira_site')}
required
rules={[
{
validator: (_, value: string): Promise<void> =>
!value || isValidJiraSiteURL(value)
? Promise.resolve()
: Promise.reject(new Error(t('jira_site_invalid'))),
},
]}
tooltip={{
title: (
<MarkdownRenderer
markdownContent={t('tooltip_jira_site')}
variables={{}}
/>
),
overlayInnerStyle: { maxWidth: 400 },
placement: 'right',
}}
>
<Input
placeholder="https://your-domain.atlassian.net"
onChange={(event): void => update({ site: event.target.value })}
data-testid="jira-site-textbox"
/>
</Form.Item>
<FormField
label={t('field_jira_email')}
help={t('help_jira_email')}
error={errors.email?.message}
required
>
<Input
{...register('email')}
aria-required
data-testid="jira-email-textbox"
/>
</FormField>
<Form.Item
name="username"
label={t('field_jira_email')}
help={t('help_jira_email')}
required
>
<Input
onChange={(event): void => update({ username: event.target.value })}
data-testid="jira-email-textbox"
/>
</Form.Item>
<FormField
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
error={errors.apiToken?.message}
required
>
<Input
{...register('apiToken')}
aria-required
type="password"
data-testid="jira-api-token-textbox"
/>
</FormField>
<Form.Item
name="password"
label={t('field_jira_api_token')}
help={t('help_jira_api_token')}
required
>
<Input
type="password"
onChange={(event): void => update({ password: event.target.value })}
data-testid="jira-api-token-textbox"
/>
</Form.Item>
<FormField
label={t('field_jira_project')}
error={errors.project?.message}
required
>
<Input
{...register('project')}
aria-required
placeholder="e.g. OPS"
data-testid="jira-project-textbox"
/>
</FormField>
<Form.Item name="project" label={t('field_jira_project')} required>
<Input
placeholder="e.g. OPS"
onChange={(event): void => update({ project: event.target.value })}
data-testid="jira-project-textbox"
/>
</Form.Item>
<FormField
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
error={errors.issueType?.message}
required
>
<Input
{...register('issueType')}
aria-required
data-testid="jira-issue-type-textbox"
/>
</FormField>
<Form.Item
name="issue_type"
label={t('field_jira_issue_type')}
help={t('help_jira_issue_type')}
required
>
<Input
onChange={(event): void => update({ issue_type: event.target.value })}
data-testid="jira-issue-type-textbox"
/>
</Form.Item>
<FormField
label={t('field_jira_summary')}
help={t('help_jira_summary')}
error={errors.summary?.message}
>
<Controller
control={control}
name="summary"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="jira-summary-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<Form.Item
name="summary"
label={t('field_jira_summary')}
help={t('help_jira_summary')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ summary: event.target.value })}
data-testid="jira-summary-textarea"
/>
</Form.Item>
<FormField
label={t('field_jira_description')}
help={t('help_jira_description')}
error={errors.description?.message}
>
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="jira-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<Form.Item
name="description"
label={t('field_jira_description')}
help={t('help_jira_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jira-description-textarea"
/>
</Form.Item>
<FormCard title={t('section_advanced')}>{advanced}</FormCard>
<Collapse
ghost
items={[
{
key: 'advanced',
label: t('jira_advanced_section'),
children: advanced,
},
]}
/>
</>
);
}
interface JiraProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JiraChannel>>>;
}
export default JiraSettings;

View File

@@ -1,143 +1,117 @@
import { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput, Select } from 'antd';
import { Collapse, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import { JsmOpsChannel } from '../../CreateAlertChannels/config';
const { TextArea } = AntdInput;
function JsmOpsSettings(): JSX.Element {
function JsmOpsSettings({ setSelectedConfig }: JsmOpsProps): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
const update = (patch: Partial<JsmOpsChannel>): void =>
setSelectedConfig((value) => ({ ...value, ...patch }));
const advanced = (
<>
<FormField
<Form.Item
name="priority"
label={t('field_jsmops_priority')}
help={t('help_jsmops_priority')}
error={errors.priority?.message}
>
<Controller
control={control}
name="priority"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="jsmops-priority-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
<Input.TextArea
rows={2}
onChange={(event): void => update({ priority: event.target.value })}
data-testid="jsmops-priority-textarea"
/>
</FormField>
</Form.Item>
<FormField
<Form.Item
name="tags"
label={t('field_jsmops_tags')}
help={t('help_jsmops_tags')}
error={errors.tags?.message}
>
{/* the API takes one comma-separated string, the control edits chips */}
<Controller
control={control}
name="tags"
render={({ field }): JSX.Element => (
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
value={field.value ? String(field.value).split(',') : []}
onChange={(value): void => field.onChange((value as string[]).join(','))}
data-testid="jsmops-tags-select"
/>
)}
<Select
mode="tags"
open={false}
placeholder={t('placeholder_jsmops_tags')}
onChange={(value): void => update({ tags: value as string[] })}
data-testid="jsmops-tags-select"
/>
</FormField>
</Form.Item>
</>
);
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="jsmops-tip"
style={{ display: 'block', marginBottom: 16 }}
<Typography.Text
color="muted"
size="sm"
testId="jsmops-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('jsmops_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/"
target="_blank"
rel="noopener noreferrer"
>
{t('jsmops_tip')}{' '}
<Typography.Link
href="https://signoz.io/docs/alerts-management/notification-channel/jsm-ops/"
target="_blank"
rel="noopener noreferrer"
>
{t('jsmops_tip_link')}
</Typography.Link>
</Typography.Text>
{t('jsmops_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
error={errors.apiKey?.message}
required
>
<Input
{...register('apiKey')}
aria-required
type="password"
data-testid="jsmops-api-key-textbox"
/>
</FormField>
<Form.Item
name="api_key"
label={t('field_jsmops_api_key')}
help={t('help_jsmops_api_key')}
required
>
<Input
type="password"
onChange={(event): void => update({ api_key: event.target.value })}
data-testid="jsmops-api-key-textbox"
/>
</Form.Item>
<FormField
label={t('field_jsmops_message')}
help={t('help_jsmops_message')}
error={errors.message?.message}
>
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="jsmops-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<Form.Item
name="message"
label={t('field_jsmops_message')}
help={t('help_jsmops_message')}
>
<Input.TextArea
rows={2}
onChange={(event): void => update({ message: event.target.value })}
data-testid="jsmops-message-textarea"
/>
</Form.Item>
<FormField
label={t('field_jsmops_description')}
help={t('help_jsmops_description')}
error={errors.description?.message}
>
<Controller
control={control}
name="description"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="jsmops-description-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<Form.Item
name="description"
label={t('field_jsmops_description')}
help={t('help_jsmops_description')}
>
<Input.TextArea
rows={6}
onChange={(event): void => update({ description: event.target.value })}
data-testid="jsmops-description-textarea"
/>
</Form.Item>
<FormCard title={t('section_advanced')}>{advanced}</FormCard>
<Collapse
ghost
items={[
{
key: 'advanced',
label: t('jsmops_advanced_section'),
children: advanced,
},
]}
/>
</>
);
}
interface JsmOpsProps {
setSelectedConfig: Dispatch<SetStateAction<Partial<JsmOpsChannel>>>;
}
export default JsmOpsSettings;

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