Compare commits

...

9 Commits

Author SHA1 Message Date
srikanthccv
8a8cfddc9f chore(openapi): regenerate frontend clients after merging main
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 21:06:13 +05:30
Srikanth Chekuri
5f3896a096 Merge branch 'main' into chore/openapi-ref-descriptions 2026-10-20 21:02:18 +05:30
Vinicius Lourenço
2cd7285e3e chore(audit): address tinypool audit (#13203)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

A tiny PR to address vanta reports, I tiny-pool was being used by
oxlint/oxfmt, so I upgraded both.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

Some files changed due to upgrade in the deps of oxfmt, and I also had
to disable react compiler since it's now supported by oxlint by default.
2026-10-20 13:02:23 +00:00
Ashwin Bhatkal
63924a0fbb refactor(alerts): read the channel pickers from the v2 list API (#13129)
#### Description

The channel pickers on the alert rule form and the routing policies read
`/api/v2/notification_channels` instead of the hand-written v1 client.

- `useChannelOptions` pages the v2 list rather than truncating, so an
org past the API's 200 limit stops losing channels from the dropdown.
- It always returns an array, so the call sites drop their `|| []`
fallbacks.
- The story mocks for the pages that render a picker move to the v2
endpoint.
2026-10-20 12:57:46 +00:00
Ashwin Bhatkal
9127c5abf7 feat(notification-channels): rebuild the channels list on the v2 API (#13128)
#### Description

The channels list, on `/api/v2/notification_channels` and the shared
table the rest of the product uses. Split out of the v2 form migration
so each can be read on its own; the form keeps running on v1 until that
lands.

- The list reads the v2 list endpoint: search, kind filter and page live
in the URL, so a filtered list survives a reload and can be shared. Row
actions open, duplicate-free delete, and a read-only JSON view of a
channel with credentials masked behind a reveal.
- Create and edit now mount inside the alerts page, so opening a channel
is a client-side navigation instead of a full remount that resets the
tab.
- The alert rule and routing policy pickers read the same v2 list. They
page rather than truncating, so an org past the API's 200 limit stops
losing channels from the dropdown.

#### Additional Information

The v1 and v2 APIs share the same row and id, so the list links straight
at the existing edit screen and it resolves the channel unchanged.

Nothing here deletes the old list; it stops being rendered and is
removed once the rest of the stack lands.
2026-10-20 12:57:45 +00:00
srikanthccv
806a51269c chore(openapi): keep field descriptions on referenced properties
OpenAPI 3.0 allows no siblings next to $ref, so a description tag on a
field whose type is a named schema (an enum or a struct) was silently
dropped from docs/api/openapi.yml. Wrap such references in allOf, the
same shape openapi-go already emits for deprecated references, so the
field-level description survives for every spec consumer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 17:06:47 +05:30
praneeth-signoz
37f3cd4403 feat(alerts): Support telegram channel for alerts (#13074)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds Telegram as a custom notifier so a rule's `_title_template` /
`_body_template`
annotations override the channel's message, matching the other custom
channels.
- Telegram's HTML parse mode accepts a fixed tag list (tags like p , br,
div , span...etc not supported)and the Bot API can reject the entire
sendMessage on anything outside it — swapping in the generic HTML
renderer stops notifications arriving at all. Hence a dedicated renderer
that emits only supported tags, flattening headings, lists, tables and
images to text.

Sample payload — what the generic renderer puts on the wire

```
{
  "chat_id": "8862729750",
  "parse_mode": "HTML",
  "disable_web_page_preview": "true",
  "text": "<b><p>renderer test (critical)</p></b>\n\n<p><strong>firing</strong> on <code>used</code></p>\n<h2>Impact</h2>\n<ul>\n<li>memory at 0.39</li>\n</ul>"
}
```

Against the same rule, the Telegram renderer sends:

```
{
  "chat_id": "8862729750",
  "parse_mode": "HTML",
  "disable_web_page_preview": "true",
  "text": "<b>renderer test (critical)</b>\n\n<b>firing</b> on <code>used</code>\n\n<b>Impact</b>\n\n• memory at 0.39"
}
```

- Verified against a private chat (positive chatId), a group (negative
chatId), and a specific forum topic within that group via
messageThreadId.
- Fixes a ruler bug: the expander rewrote every `$`-ref into a label
lookup,
blanking private annotations before any notifier saw them. Per-rule
templates
  never worked end to end for any channel until now. 
Check the screenshot of how it looks without the fix and with the fix
for the same template

- Drops `parseMode` from the Telegram channel spec. HTML is the only
mode rendered
for, and the notifier and the channel read path both reject anything
else.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

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

#### Screenshots
<img width="250" height="550" alt="File"
src="https://github.com/user-attachments/assets/2d568a41-2d96-4fee-9575-a5867e34c804"
/>

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- `parse_mode` still appears in `openapi.yml` under
`ConfigTelegramConfigDTO`.
That's upstream's `config.TelegramConfig`, a different schema from ours.
- `$alert.value`, `$rule.threshold.value`, `$rule.compare_op` and
`$rule.match_type` render empty because no rule type attaches those
annotations.
Attaching them adds rows to MS Teams cards and values to Opsgenie
descriptions,
  so it's left as a follow-up.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-10-20 11:00:20 +00:00
Ashwin Bhatkal
65fc5a9cbb ci(e2e): stop label events from failing the e2e check (#13167)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adding any label to a PR left failed `e2e` checks next to the real
result. A `pull_request_target` run failed as unauthorized, and the
`labeled` run cancelled the in-flight e2e run, whose `e2e` job then
reported failure.
- e2eci now runs only on `pull_request` opened / synchronize / reopened,
so each commit gets its `e2e` check from a single run.
- Labels are read live in the `changes` job. The new `e2elabelci`
workflow re-runs the commit's e2eci run when `safe-to-test` or
`safe-to-e2e` is added, so the labels still start e2e right away without
adding a second `e2e` check. It never checks out PR code.
- Fork and dependabot PRs still need `safe-to-test`, and run only on a
re-run, because `postci` strips the label on push and a first attempt
could read it before that lands.
- Fork e2e now runs the PR's code under `pull_request` with no secrets.
Under `pull_request_target`, checkout defaulted to the base branch, so
fork runs tested `main` instead of the PR.

---------

Co-authored-by: Aswin <aswin.km@signoz.io>
Co-authored-by: Aswin K.M. <astrobot7@gmail.com>
2026-10-19 15:29:43 +00:00
Aditya Singh
03bdeb9ba4 feat(saved-views): turn on saved views and the bottom strip (#13125)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- This PR turns on saved views and the bottom strip for all users. The
`SAVED_VIEW_ENABLED` flag now always reads as on.
- The flag code stays in place. The cleanup in
https://github.com/SigNoz/events-pod/issues/77 removes it.
- The old saved views URLs now redirect to their explorer through the
existing old-routes redirects:
  - `/logs/saved-views` to `/logs/logs-explorer`
  - `/traces/saved-views` to `/traces-explorer`
  - `/metrics-explorer/views` to `/metrics-explorer/explorer`
  - `/meter/explorer/views` to `/meter/explorer`

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/57

#### Additional Information
- Merge this PR after #13022, #13028 and #13027. Without them, the
explorers show a placeholder views panel next to the old bottom bar.
- The flag also hides the Pylon chat bubble and the chat support
gateway, because Support moves into the bottom strip.
2026-10-19 13:52:25 +00:00
122 changed files with 4980 additions and 1074 deletions

View File

@@ -2,25 +2,13 @@ name: e2eci
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
concurrency:
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ 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 }}
@@ -30,17 +18,21 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
FORCE: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
run: |
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
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
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: |
((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'
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -70,9 +62,7 @@ jobs:
- chromium
shard: [1, 2, 3, 4]
needs: changes
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'
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -181,17 +171,14 @@ 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,
# including fork and dependabot PRs still waiting on safe-to-test.
# Required status check. Fails when e2e was needed but did not pass.
e2e:
if: always()
needs: [changes, fmtlint, test]
runs-on: ubuntu-latest
steps:
- name: verdict
if: |
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
echo "::error::e2e did not pass."
exit 1

View File

@@ -251,6 +251,7 @@ components:
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
propertyName: kind
oneOf:
@@ -264,6 +265,7 @@ components:
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
type: object
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
properties:
@@ -373,6 +375,18 @@ 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:
@@ -525,6 +539,7 @@ components:
- jira
- jsmops
- incidentio
- telegram
type: string
AlertmanagertypesChannelListOrder:
enum:
@@ -729,6 +744,31 @@ 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:
@@ -1022,6 +1062,8 @@ components:
- jsmops_configs
- required:
- incidentio_configs
- required:
- telegram_configs
- required:
- discord_configs
- required:
@@ -1042,8 +1084,6 @@ components:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
@@ -1119,7 +1159,7 @@ components:
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
$ref: '#/components/schemas/AlertmanagertypesTelegramReceiverConfig'
type: array
victorops_configs:
items:
@@ -1263,7 +1303,7 @@ components:
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
$ref: '#/components/schemas/AlertmanagertypesTelegramReceiverConfig'
type: array
victorops_configs:
items:
@@ -1334,6 +1374,34 @@ 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:
@@ -1374,9 +1442,13 @@ components:
AuthtypesAuthDomainConfigGoogle:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
allOf:
- $ref: '#/components/schemas/AuthtypesAuthNProvider'
description: The kind of authn provider.
spec:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
allOf:
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
description: The google auth configuration.
required:
- kind
- spec
@@ -1384,9 +1456,13 @@ components:
AuthtypesAuthDomainConfigOIDC:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
allOf:
- $ref: '#/components/schemas/AuthtypesAuthNProvider'
description: The kind of authn provider.
spec:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
allOf:
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
description: The oidc configuration.
required:
- kind
- spec
@@ -1394,9 +1470,13 @@ components:
AuthtypesAuthDomainConfigSAML:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
allOf:
- $ref: '#/components/schemas/AuthtypesAuthNProvider'
description: The kind of authn provider.
spec:
$ref: '#/components/schemas/AuthtypesSamlConfig'
allOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
description: The saml configuration.
required:
- kind
- spec
@@ -8152,9 +8232,13 @@ components:
Querybuildertypesv5BucketOptionsLinear:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
description: How the upper bounds are spaced.
spec:
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
description: The evenly spaced bucket specification.
required:
- kind
- spec
@@ -8162,9 +8246,13 @@ components:
Querybuildertypesv5BucketOptionsLog:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
description: How the upper bounds are spaced.
spec:
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
description: The logarithmic bucket specification.
required:
- kind
- spec
@@ -8753,54 +8841,78 @@ components:
Querybuildertypesv5QueryEnvelopeBuilder:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5BuilderQuerySpec'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5BuilderQuerySpec'
description: The builder query specification.
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeBuilderAI:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
description: The AI builder query specification.
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
description: The ClickHouse SQL query specification.
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeFormula:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
description: The formula specification.
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
required:
- type
type: object
Querybuildertypesv5QueryEnvelopePromQL:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5PromQuery'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5PromQuery'
description: The PromQL query specification.
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeTraceOperator:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
description: The trace operator specification.
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryType'
description: The type of the query.
required:
- type
type: object
@@ -9360,9 +9472,13 @@ components:
RuletypesEvaluationCumulative:
properties:
kind:
$ref: '#/components/schemas/RuletypesEvaluationKind'
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationKind'
description: The kind of evaluation.
spec:
$ref: '#/components/schemas/RuletypesCumulativeWindow'
allOf:
- $ref: '#/components/schemas/RuletypesCumulativeWindow'
description: The cumulative window evaluation specification.
required:
- kind
- spec
@@ -9385,9 +9501,13 @@ components:
RuletypesEvaluationRolling:
properties:
kind:
$ref: '#/components/schemas/RuletypesEvaluationKind'
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationKind'
description: The kind of evaluation.
spec:
$ref: '#/components/schemas/RuletypesRollingWindow'
allOf:
- $ref: '#/components/schemas/RuletypesRollingWindow'
description: The rolling window evaluation specification.
required:
- kind
- spec
@@ -9768,9 +9888,13 @@ components:
RuletypesThresholdBasic:
properties:
kind:
$ref: '#/components/schemas/RuletypesThresholdKind'
allOf:
- $ref: '#/components/schemas/RuletypesThresholdKind'
description: The kind of threshold.
spec:
$ref: '#/components/schemas/RuletypesBasicRuleThresholds'
allOf:
- $ref: '#/components/schemas/RuletypesBasicRuleThresholds'
description: The basic threshold specification (array of thresholds).
required:
- kind
- spec

View File

@@ -277,10 +277,7 @@ 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 := 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)})
}
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -262,23 +262,20 @@
// 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
// 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",
// 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",
"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",
@@ -582,6 +579,16 @@
"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

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

View File

@@ -300,20 +300,17 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
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 });
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',
);
expect(screen.getByTestId('location-display').textContent).toBe(to);
});
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
@@ -1635,11 +1632,6 @@ 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,8 +513,12 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/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',
'/settings/access-tokens': '/settings/service-accounts',
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',

View File

@@ -220,8 +220,7 @@ export type ClarificationSummaryDTODiscoveredContextAnyOf = {
[key: string]: unknown;
};
export type ClarificationSummaryDTODiscoveredContext =
ClarificationSummaryDTODiscoveredContextAnyOf | null;
export type ClarificationSummaryDTODiscoveredContext = ClarificationSummaryDTODiscoveredContextAnyOf | null;
export interface ClarificationSummaryDTO {
/**
@@ -784,8 +783,7 @@ 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}).
@@ -975,8 +973,7 @@ export type ClarificationEventDTODiscoveredContextAnyOf = {
[key: string]: unknown;
};
export type ClarificationEventDTODiscoveredContext =
ClarificationEventDTODiscoveredContextAnyOf | null;
export type ClarificationEventDTODiscoveredContext = ClarificationEventDTODiscoveredContextAnyOf | null;
export interface ClarificationEventDTO {
/**
@@ -1299,8 +1296,7 @@ export type CreateThreadApiV1AssistantThreadsPostHeaders = {
'X-SigNoz-URL'?: string | null;
};
export type CreateThreadApiV1AssistantThreadsPostBody =
CreateThreadRequestDTO | null;
export type CreateThreadApiV1AssistantThreadsPostBody = CreateThreadRequestDTO | null;
export type ListThreadsApiV1AssistantThreadsGetParams = {
/**

View File

@@ -431,8 +431,7 @@ export type AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelEmailConfigDTOHeaders =
AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
export type AlertmanagertypesChannelEmailConfigDTOHeaders = AlertmanagertypesChannelEmailConfigDTOHeadersAnyOf | null;
export interface AlertmanagertypesChannelEmailConfigDTO {
/**
@@ -510,8 +509,7 @@ export type AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelPagerdutyConfigDTODetails =
AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = AlertmanagertypesChannelPagerdutyConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
/**
@@ -584,8 +582,7 @@ export type AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelOpsgenieConfigDTODetails =
AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = AlertmanagertypesChannelOpsgenieConfigDTODetailsAnyOf | null;
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
/**
@@ -706,8 +703,7 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelJiraConfigDTOCustomFields =
AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = AlertmanagertypesChannelJiraConfigDTOCustomFieldsAnyOf | null;
export interface AlertmanagertypesChannelJiraConfigDTO {
/**
@@ -832,8 +828,7 @@ export type AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf = {
/**
* @nullable
*/
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata =
AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = AlertmanagertypesChannelIncidentIOConfigDTOMetadataAnyOf | null;
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
/**
@@ -872,6 +867,55 @@ 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
@@ -882,7 +926,8 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
@@ -902,6 +947,7 @@ export enum AlertmanagertypesChannelKindDTO {
jira = 'jira',
jsmops = 'jsmops',
incidentio = 'incidentio',
telegram = 'telegram',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
@@ -2462,7 +2508,7 @@ export interface ConfigSNSConfigDTO {
topic_arn?: string;
}
export interface ConfigTelegramConfigDTO {
export interface AlertmanagertypesTelegramReceiverConfigDTO {
api_url?: ConfigURLType2DTO;
/**
* @type integer
@@ -2494,6 +2540,10 @@ export interface ConfigTelegramConfigDTO {
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
title?: string;
/**
* @type string
*/
@@ -2699,7 +2749,7 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
telegram_configs?: ConfigTelegramConfigDTO[];
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
/**
* @type array
*/
@@ -2846,7 +2896,7 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
telegram_configs?: ConfigTelegramConfigDTO[];
telegram_configs?: AlertmanagertypesTelegramReceiverConfigDTO[];
/**
* @type array
*/
@@ -2899,6 +2949,9 @@ export interface AuthtypesAttributeMappingDTO {
role?: string;
}
/**
* The kind of authn provider.
*/
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
saml = 'saml',
}
@@ -2926,11 +2979,18 @@ 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',
}
@@ -2979,11 +3039,18 @@ 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',
}
@@ -3020,8 +3087,12 @@ export interface AuthtypesAuthDomainConfigOIDCDTO {
/**
* @type string
* @enum oidc
* @description The kind of authn provider.
*/
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
/**
* @description The oidc configuration.
*/
spec: AuthtypesOIDCConfigDTO;
}
@@ -3080,8 +3151,7 @@ export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export type AuthtypesRoleMappingDTOGroupMappings = AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
@@ -3692,8 +3762,7 @@ export type CloudintegrationtypesAgentReportDTODataAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesAgentReportDTOData =
CloudintegrationtypesAgentReportDTODataAnyOf | null;
export type CloudintegrationtypesAgentReportDTOData = CloudintegrationtypesAgentReportDTODataAnyOf | null;
/**
* @nullable
@@ -4179,8 +4248,7 @@ export type CloudintegrationtypesPostableAgentCheckInDTODataAnyOf = {
/**
* @nullable
*/
export type CloudintegrationtypesPostableAgentCheckInDTOData =
CloudintegrationtypesPostableAgentCheckInDTODataAnyOf | null;
export type CloudintegrationtypesPostableAgentCheckInDTOData = CloudintegrationtypesPostableAgentCheckInDTODataAnyOf | null;
export interface CloudintegrationtypesPostableAgentCheckInDTO {
/**
@@ -4422,6 +4490,48 @@ 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;
/**
@@ -4483,6 +4593,9 @@ export interface Querybuildertypesv5TraceAggregationDTO {
expression?: string;
}
/**
* How the upper bounds are spaced.
*/
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
linear = 'linear',
}
@@ -4502,11 +4615,18 @@ 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',
}
@@ -4521,8 +4641,12 @@ export interface Querybuildertypesv5BucketOptionsLogDTO {
/**
* @type string
* @enum log
* @description How the upper bounds are spaced.
*/
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
/**
* @description The logarithmic bucket specification.
*/
spec: Querybuildertypesv5LogBucketsSpecDTO;
}
@@ -4868,8 +4992,7 @@ export type DashboardtypesLegendDTOCustomColorsAnyOf = {
/**
* @nullable
*/
export type DashboardtypesLegendDTOCustomColors =
DashboardtypesLegendDTOCustomColorsAnyOf | null;
export type DashboardtypesLegendDTOCustomColors = DashboardtypesLegendDTOCustomColorsAnyOf | null;
export interface DashboardtypesLegendDTO {
/**
@@ -5461,8 +5584,7 @@ export type DashboardtypesTableFormattingDTOColumnUnitsAnyOf = {
/**
* @nullable
*/
export type DashboardtypesTableFormattingDTOColumnUnits =
DashboardtypesTableFormattingDTOColumnUnitsAnyOf | null;
export type DashboardtypesTableFormattingDTOColumnUnits = DashboardtypesTableFormattingDTOColumnUnitsAnyOf | null;
export interface DashboardtypesTableFormattingDTO {
/**
@@ -5747,26 +5869,40 @@ 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;
}
@@ -5804,14 +5940,21 @@ 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;
}
@@ -5874,14 +6017,21 @@ 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;
}
@@ -5910,14 +6060,21 @@ 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;
}
@@ -5941,14 +6098,21 @@ 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;
}
@@ -6777,8 +6941,7 @@ export type FeaturetypesGettableFeatureDTOVariantsAnyOf = {
/**
* @nullable
*/
export type FeaturetypesGettableFeatureDTOVariants =
FeaturetypesGettableFeatureDTOVariantsAnyOf | null;
export type FeaturetypesGettableFeatureDTOVariants = FeaturetypesGettableFeatureDTOVariantsAnyOf | null;
export interface FeaturetypesGettableFeatureDTO {
/**
@@ -7256,8 +7419,7 @@ export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesClusterRecordDTOMeta =
InframonitoringtypesClusterRecordDTOMetaAnyOf | null;
export type InframonitoringtypesClusterRecordDTOMeta = InframonitoringtypesClusterRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNodeCountsByReadinessDTO {
/**
@@ -7530,8 +7692,7 @@ export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export type InframonitoringtypesContainerRecordDTOMeta = InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesContainerRecordDTO {
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
@@ -7622,8 +7783,7 @@ export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesDaemonSetRecordDTOMeta =
InframonitoringtypesDaemonSetRecordDTOMetaAnyOf | null;
export type InframonitoringtypesDaemonSetRecordDTOMeta = InframonitoringtypesDaemonSetRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesDaemonSetRecordDTO {
/**
@@ -7718,8 +7878,7 @@ export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesDeploymentRecordDTOMeta =
InframonitoringtypesDeploymentRecordDTOMetaAnyOf | null;
export type InframonitoringtypesDeploymentRecordDTOMeta = InframonitoringtypesDeploymentRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesDeploymentRecordDTO {
/**
@@ -7808,8 +7967,7 @@ export type InframonitoringtypesHostRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesHostRecordDTOMeta =
InframonitoringtypesHostRecordDTOMetaAnyOf | null;
export type InframonitoringtypesHostRecordDTOMeta = InframonitoringtypesHostRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesHostRecordDTO {
/**
@@ -7891,8 +8049,7 @@ export type InframonitoringtypesJobRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesJobRecordDTOMeta =
InframonitoringtypesJobRecordDTOMetaAnyOf | null;
export type InframonitoringtypesJobRecordDTOMeta = InframonitoringtypesJobRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesJobRecordDTO {
/**
@@ -8010,8 +8167,7 @@ export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export type InframonitoringtypesNamespaceRecordDTOMeta = InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
@@ -8078,8 +8234,7 @@ export type InframonitoringtypesNodeRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesNodeRecordDTOMeta =
InframonitoringtypesNodeRecordDTOMetaAnyOf | null;
export type InframonitoringtypesNodeRecordDTOMeta = InframonitoringtypesNodeRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNodeRecordDTO {
condition: InframonitoringtypesNodeConditionDTO;
@@ -8150,8 +8305,7 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export type InframonitoringtypesPodRecordDTOMeta = InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesPodRecordDTO {
/**
@@ -8538,8 +8692,7 @@ export type InframonitoringtypesStatefulSetRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesStatefulSetRecordDTOMeta =
InframonitoringtypesStatefulSetRecordDTOMetaAnyOf | null;
export type InframonitoringtypesStatefulSetRecordDTOMeta = InframonitoringtypesStatefulSetRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesStatefulSetRecordDTO {
/**
@@ -8615,8 +8768,7 @@ export type InframonitoringtypesVolumeRecordDTOMetaAnyOf = {
/**
* @nullable
*/
export type InframonitoringtypesVolumeRecordDTOMeta =
InframonitoringtypesVolumeRecordDTOMetaAnyOf | null;
export type InframonitoringtypesVolumeRecordDTOMeta = InframonitoringtypesVolumeRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesVolumeRecordDTO {
/**
@@ -9734,8 +9886,7 @@ export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
/**
* @nullable
*/
export type PrometheusMatrixSeriesSchemaDTOMetric =
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
export type PrometheusMatrixSeriesSchemaDTOMetric = PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
export interface PrometheusMatrixSeriesSchemaDTO {
/**
@@ -9767,8 +9918,7 @@ export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
/**
* @nullable
*/
export type PrometheusVectorSampleSchemaDTOMetric =
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
export type PrometheusVectorSampleSchemaDTOMetric = PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
export interface PrometheusVectorSampleSchemaDTO {
/**
@@ -10178,8 +10328,7 @@ export type Querybuildertypesv5RawRowDTODataAnyOf = { [key: string]: unknown };
/**
* @nullable
*/
export type Querybuildertypesv5RawRowDTOData =
Querybuildertypesv5RawRowDTODataAnyOf | null;
export type Querybuildertypesv5RawRowDTOData = Querybuildertypesv5RawRowDTODataAnyOf | null;
export interface Querybuildertypesv5RawRowDTO {
/**
@@ -10250,8 +10399,7 @@ 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.
@@ -10651,6 +10799,9 @@ export interface RuletypesCumulativeWindowDTO {
timezone: string;
}
/**
* The kind of evaluation.
*/
export enum RuletypesEvaluationCumulativeDTOKind {
cumulative = 'cumulative',
}
@@ -10658,11 +10809,18 @@ 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',
}
@@ -10681,8 +10839,12 @@ export interface RuletypesEvaluationRollingDTO {
/**
* @type string
* @enum rolling
* @description The kind of evaluation.
*/
kind: RuletypesEvaluationRollingDTOKind;
/**
* @description The rolling window evaluation specification.
*/
spec: RuletypesRollingWindowDTO;
}
@@ -10888,16 +11050,28 @@ 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;
spec: RuletypesBasicRuleThresholdsDTO | null;
/**
* @description The basic threshold specification (array of thresholds).
*/
spec: RuletypesThresholdBasicDTOSpec;
}
export type RuletypesRuleThresholdDataDTO = RuletypesThresholdBasicDTO;
@@ -11560,8 +11734,7 @@ export type SpantypesSpanMapperTestSpanDTOAttributesAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOAttributes =
SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOAttributes = SpantypesSpanMapperTestSpanDTOAttributesAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
[key: string]: unknown;
@@ -11570,8 +11743,7 @@ export type SpantypesSpanMapperTestSpanDTOResourceAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanMapperTestSpanDTOResource =
SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export type SpantypesSpanMapperTestSpanDTOResource = SpantypesSpanMapperTestSpanDTOResourceAnyOf | null;
export interface SpantypesSpanMapperTestSpanDTO {
/**
@@ -11683,8 +11855,7 @@ export type SpantypesSpanAggregationResultDTOValueAnyOf = {
/**
* @nullable
*/
export type SpantypesSpanAggregationResultDTOValue =
SpantypesSpanAggregationResultDTOValueAnyOf | null;
export type SpantypesSpanAggregationResultDTOValue = SpantypesSpanAggregationResultDTOValueAnyOf | null;
export interface SpantypesSpanAggregationResultDTO {
aggregation: SpantypesSpanAggregationTypeDTO;
@@ -11886,16 +12057,14 @@ 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 {
/**
@@ -12335,8 +12504,7 @@ export type TelemetrytypesGettableFieldKeysDTOKeysAnyOf = {
/**
* @nullable
*/
export type TelemetrytypesGettableFieldKeysDTOKeys =
TelemetrytypesGettableFieldKeysDTOKeysAnyOf | null;
export type TelemetrytypesGettableFieldKeysDTOKeys = TelemetrytypesGettableFieldKeysDTOKeysAnyOf | null;
export interface TelemetrytypesGettableFieldKeysDTO {
/**

View File

@@ -165,17 +165,15 @@ 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
@@ -187,14 +185,12 @@ 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),
@@ -397,17 +393,15 @@ 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
@@ -416,28 +410,24 @@ 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,
})),
};
}
@@ -610,14 +600,12 @@ 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

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

View File

@@ -111,14 +111,11 @@ 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,21 +175,19 @@ 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} />}
</div>
{selectedItem === item.key && <Check size={12} />}
</div>
),
)}
</div>
))}
</div>
</div>

View File

@@ -11,13 +11,8 @@
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,13 +12,8 @@ $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;
@@ -138,8 +133,7 @@ $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,26 +13,22 @@ 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,14 +50,11 @@ 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

@@ -13,20 +13,19 @@ 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

@@ -1,12 +1,8 @@
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 { SuccessResponseV2 } from 'types/api';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
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';
@@ -21,14 +17,11 @@ function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const {
data,
data: channels,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
} = useChannelOptions();
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 { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: Channels[] = [];
const mockChannels: ChannelOption[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
@@ -85,16 +85,17 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
// Mock getAllChannels API
jest.mock('api/channels/getAll', () => ({
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
useChannelOptions: jest.fn(() => ({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as ChannelOption[],
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
}));
// 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 { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
@@ -57,7 +57,7 @@ const mockThreshold = {
color: '#ff0000',
};
const mockChannels: Channels[] = [
const mockChannels: ChannelOption[] = [
{
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 { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import {
NotificationSettingsAction,
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
}
export interface AnomalyAndThresholdProps {
channels: Channels[];
channels: ChannelOption[];
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

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

View File

@@ -2,10 +2,10 @@ import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Select, Spin } from 'antd';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { StyledCreateChannelOption, StyledSelect } from './styles';
@@ -16,7 +16,7 @@ export interface ChannelSelectProps {
onSelectChannels: (s: string[]) => void;
onDropdownOpen: () => void;
isLoading: boolean;
channels: Channels[];
channels: ChannelOption[];
hasError: boolean;
error: APIError;
handleCreateNewChannels: () => void;

View File

@@ -66,9 +66,9 @@ jest.mock('../MetricsTable', () => ({
__esModule: true,
MetricsTable: jest
.fn()
.mockImplementation(
(): JSX.Element => <div data-testid="metrics-table">Metrics Table</div>,
),
.mockImplementation((): JSX.Element => (
<div data-testid="metrics-table">Metrics Table</div>
)),
}));
const mockUseQuery = jest.fn();

View File

@@ -111,9 +111,9 @@ const POD_STATUS_CATEGORY_MAP: Record<
};
type ErrorStatusKey = {
[K in keyof InframonitoringtypesPodCountsByStatusDTO]: (typeof POD_STATUS_CATEGORY_MAP)[K] extends 'error'
? K
: never;
[
K in keyof InframonitoringtypesPodCountsByStatusDTO
]: (typeof POD_STATUS_CATEGORY_MAP)[K] extends 'error' ? K : never;
}[keyof InframonitoringtypesPodCountsByStatusDTO];
const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {

View File

@@ -74,12 +74,7 @@
}
tr:has(.active-log) {
background-color: rgba(
78,
116,
248,
0.5
) !important; // same as bg-robin-500
background-color: rgba(78, 116, 248, 0.5) !important; // same as bg-robin-500
color: var(--l1-foreground) !important;
}
}

View File

@@ -14,6 +14,7 @@ import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
import * as appContextHooks from 'providers/App/App';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
@@ -147,7 +148,9 @@ function renderExplorer(): void {
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
<PreferenceContextProvider>
<Explorer />
</PreferenceContextProvider>
</ErrorModalProvider>
</Provider>
</MemoryRouter>

View File

@@ -15,16 +15,14 @@ function PromQLQueryContainer(): JSX.Element | null {
return (
<>
{currentQuery.promql.map(
(q: IPromQLQuery, idx: number): JSX.Element => (
<PromQLQueryBuilder
key={q.name}
deletable={currentQuery.promql.length > 1}
queryIndex={idx}
queryData={q}
/>
),
)}
{currentQuery.promql.map((q: IPromQLQuery, idx: number): JSX.Element => (
<PromQLQueryBuilder
key={q.name}
deletable={currentQuery.promql.length > 1}
queryIndex={idx}
queryData={q}
/>
))}
<QueryButton
onClick={addQueryHandler}
icon={<Plus size="md" />}

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,25 +62,21 @@ export const getAbbreviatedLabel = (label: string): string => {
export const showAllDataSetFromApiResponse = (
apiResponse: QueryData[],
): LegendEntryProps[] =>
apiResponse.map(
(item): LegendEntryProps => ({
label: getLabelName(
item.metric || {},
item.queryName || '',
item.legend || '',
),
show: true,
}),
);
apiResponse.map((item): LegendEntryProps => ({
label: getLabelName(
item.metric || {},
item.queryName || '',
item.legend || '',
),
show: true,
}));
export const showAllDataSet = (options: uPlot.Options): LegendEntryProps[] =>
options.series
.map(
(item): LegendEntryProps => ({
label: item.label || '',
show: true,
}),
)
.map((item): LegendEntryProps => ({
label: item.label || '',
show: true,
}))
.filter((_, index) => index !== 0);
export const saveLegendEntriesToLocalStorage = ({
@@ -90,12 +86,10 @@ export const saveLegendEntriesToLocalStorage = ({
}: SaveLegendEntriesToLocalStoreProps): void => {
const newLegendEntry = {
name,
dataIndex: options.series.map(
(item, index): LegendEntryProps => ({
label: item.label || '',
show: graphVisibilityState[index],
}),
),
dataIndex: options.series.map((item, index): LegendEntryProps => ({
label: item.label || '',
show: graphVisibilityState[index],
})),
};
let existingEntries: { name: string; dataIndex: LegendEntryProps[] }[] = [];

View File

@@ -0,0 +1,68 @@
import { useQuery, UseQueryResult } from 'react-query';
import { listNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
const PAGE_SIZE = 100;
export const CHANNEL_OPTIONS_QUERY_KEY = ['notificationChannelOptions'];
export interface ChannelOption {
id: string;
/** The display name, which is what rules and routing policies reference. */
name: string;
}
export type UseChannelOptionsResult = Omit<
UseQueryResult<ChannelOption[], Error>,
'data'
> & {
data: ChannelOption[];
};
function fetchPage(
offset: number,
): ReturnType<typeof listNotificationChannels> {
return listNotificationChannels({
limit: PAGE_SIZE,
offset,
sort: AlertmanagertypesChannelListSortDTO.name,
order: AlertmanagertypesChannelListOrderDTO.asc,
});
}
/**
* Every channel, for the pickers that let a rule or a policy name one. The list
* API is paginated and silently clamps a larger limit, so the first page reports
* the total and any remainder is fetched alongside rather than truncating. One
* request covers an org with a page's worth of channels or fewer.
*/
async function fetchAllChannels(): Promise<ChannelOption[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
);
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [
...first.data.channels,
...rest.flatMap((page) => page.data.channels),
];
return channels.map((channel) => ({
id: channel.id,
name: channel.displayName,
}));
}
export function useChannelOptions(): UseChannelOptionsResult {
const query = useQuery<ChannelOption[], Error>(
CHANNEL_OPTIONS_QUERY_KEY,
fetchAllChannels,
);
return { ...query, data: query.data ?? [] };
}

View File

@@ -1,11 +1,3 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
return true;
}

View File

@@ -82,10 +82,9 @@ export function applyAuthZ(
items: AuthZDropdownItemType[],
state: GateState,
): DropdownItemType[] {
return items.map(
(item): DropdownItemType =>
item.type === 'group'
? { ...item, items: item.items.map((child) => gateRow(child, state)) }
: gateRow(item, state),
return items.map((item): DropdownItemType =>
item.type === 'group'
? { ...item, items: item.items.map((child) => gateRow(child, state)) }
: gateRow(item, state),
);
}

View File

@@ -241,9 +241,9 @@ describe('Tooltip renderTooltipFooter', () => {
});
it('renders content returned by renderTooltipFooter', () => {
const renderTooltipFooter = jest.fn(
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
);
const renderTooltipFooter = jest.fn((): JSX.Element => (
<div data-testid="custom-tooltip-footer">Footer</div>
));
renderTooltip({ renderTooltipFooter });

View File

@@ -51,3 +51,38 @@ export const opsGeniePriorityDefaultValue =
export const pagerDutySeverityTextDefaultValue =
'{{ (index .Alerts 0).Labels.severity }}';
export const notificationChannelsV2 = [
{
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
kind: 'slack',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
},
{
id: '4',
name: 'oncall-pagerduty',
displayName: 'Oncall PagerDuty',
kind: 'pagerduty',
createdAt: '2024-02-03T04:45:19.239344617Z',
updatedAt: '2024-06-28T11:37:14.841184399Z',
},
];
export const notificationChannelV2 = {
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/dummy',
channel: '#dummy_channel',
sendResolved: true,
},
},
};

View File

@@ -2,7 +2,11 @@ import { rest } from 'msw';
import commonEnTranslation from '../../public/locales/en/common.json';
import enTranslation from '../../public/locales/en/translation.json';
import { allAlertChannels } from './__mockdata__/alerts';
import {
allAlertChannels,
notificationChannelsV2,
notificationChannelV2,
} from './__mockdata__/alerts';
import { alertRulesFixture } from './__mockdata__/alert_rules';
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
import { billingSuccessResponse } from './__mockdata__/billing';
@@ -205,6 +209,32 @@ export const handlers = [
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
),
rest.get('http://localhost/api/v2/notification_channels', (req, res, ctx) => {
const query = req.url.searchParams.get('query')?.toLowerCase() ?? '';
const kind = req.url.searchParams.get('kind');
const channels = notificationChannelsV2.filter(
(channel) =>
(!query || channel.displayName.toLowerCase().includes(query)) &&
(!kind || channel.kind === kind),
);
return res(
ctx.status(200),
ctx.json({
data: { channels, total: channels.length },
status: 'success',
}),
);
}),
rest.get('http://localhost/api/v2/notification_channels/:id', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: notificationChannelV2, status: 'success' }),
),
),
rest.delete(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) => res(ctx.status(204)),
),
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),

View File

@@ -24,7 +24,7 @@ import {
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
FIRST_RULE_NAME,
type AlertSchema,
@@ -148,8 +148,8 @@ export const alertHistoryMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(CHANNEL_MAX)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(CHANNEL_MAX)),
),
],
config: () => ({

View File

@@ -12,7 +12,7 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
RULE_STATE_CHOICES,
SEVERITY_CHOICES,
@@ -116,8 +116,8 @@ export const alertOverviewMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(

View File

@@ -10,7 +10,8 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
CHANNEL_MAX,
channelsResponse,
notificationChannelByRowId,
notificationChannelsPage,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
@@ -27,8 +28,15 @@ export const channelsMocks = defineStoryMocks({
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels/:id',
response.json((req) => notificationChannelByRowId(String(req.params.id))),
),
rest.get(
'http://localhost/api/v2/notification_channels',
response.json((req) =>
notificationChannelsPage(values.channels, req.url.searchParams),
),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -13,12 +14,14 @@ const pageStory = storyMocks(channelsMocks, { layout: 'app' });
/**
* Notification channels tab: what a rule can notify, one row per channel.
* Search, the type filter and paging all run against the API, so each one is
* a request rather than a filter over rows already on screen.
*
* Route: `/alerts?tab=Channels`.
*/
const meta = {
title: 'Pages/Alerts/Channels/List',
tags: ['role-gated'],
tags: ['play'],
component: AlertList,
...pageStory,
parameters: { ...pageStory.parameters },
@@ -28,9 +31,27 @@ export default meta;
type Story = StoryObj<ChannelsArgs>;
/** The list settles after its own request, so the first row outlives the default wait. */
const UNTIL_LOADED = { timeout: 15_000 };
/**
* A row's permission check only starts once its menu is open, so the items are
* disabled for a moment and a click into that gap lands on nothing.
*/
const clickRowAction = async (name: string): Promise<void> => {
const item = await screen.findByRole('menuitem', { name }, UNTIL_LOADED);
await waitFor(() => {
void expect(item).not.toHaveAttribute('aria-disabled', 'true');
void expect(item.closest('[data-disabled]')).toBeNull();
}, UNTIL_LOADED);
await userEvent.click(item);
};
/**
* Where notifications go: every configured channel with the integration it
* sends through.
* sends through, when it was made and when it last changed.
*/
export const Default: Story = {};
@@ -52,10 +73,122 @@ export const LoadError: Story = {
parameters: { allowConsoleErrors: true },
};
/**
* A viewer: the Action column and the New Alert Channel button are gone, and
* the button explains who to ask.
*/
export const Viewer: Story = {
args: { access: 'viewer' },
/** Interaction: typing a name narrows the list to the channel that matches. */
export const Search: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('sre-email', undefined, UNTIL_LOADED);
await userEvent.type(canvas.getByTestId('channels-search'), 'slack');
// the table holds the previous page while the debounced request is in
// flight, so the row going away is what says the search landed
await waitFor(
() => expect(canvas.queryByText('sre-email')).not.toBeInTheDocument(),
UNTIL_LOADED,
);
await canvas.findByText('ops-slack');
},
};
/** Interaction: the type filter keeps only the channels of the kind it names. */
export const KindFilter: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByTestId('channels-kind-filter'));
await userEvent.click(await screen.findByRole('option', { name: 'Email' }));
await waitFor(
() => expect(canvas.queryByText('ops-slack')).not.toBeInTheDocument(),
UNTIL_LOADED,
);
await canvas.findByText('sre-email');
},
};
/** Interaction: a search matching nothing offers to clear itself. */
export const NoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.type(
canvas.getByTestId('channels-search'),
'no-such-channel',
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
},
};
/** Interaction: a row's actions menu, with open, JSON and delete on it. */
export const RowActions: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(
canvas.getByRole('button', { name: 'Actions for ops-slack' }),
);
await screen.findByRole('menu');
await screen.findByRole('menuitem', { name: 'View JSON' }, UNTIL_LOADED);
},
};
/**
* Interaction: the JSON view of a channel, with its secrets masked until the
* toolbar is asked to reveal them.
*/
export const ChannelJson: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(
canvas.getByRole('button', { name: 'Actions for ops-slack' }),
);
await clickRowAction('View JSON');
await screen.findByTestId('channel-json-dialog', undefined, UNTIL_LOADED);
await screen.findByTestId('channel-json-toggle-secrets');
},
};
/** Interaction: deleting a channel asks first, naming what stops reaching it. */
export const DeleteConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(
canvas.getByRole('button', { name: 'Actions for ops-slack' }),
);
await clickRowAction('Delete');
// the name sits in its own element, so the sentence spans several nodes
const confirmation = await screen.findByRole('dialog');
await expect(confirmation.textContent).toContain(
'Are you sure you want to delete the ops-slack channel?',
);
},
};
/** Interaction: Copy Link confirms it put the channel's URL on the clipboard. */
export const CopyLink: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(
canvas.getByRole('button', { name: 'Actions for ops-slack' }),
);
await clickRowAction('Copy Link');
await screen.findByText('Link copied to clipboard', undefined, UNTIL_LOADED);
},
};

View File

@@ -0,0 +1,141 @@
.container {
display: flex;
flex-direction: column;
height: calc(100vh - 62px);
min-height: 400px;
}
.headerBand {
--control-height: 2rem;
--combobox-trigger-height: var(--control-height);
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
dashboards list opens. */
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.title {
margin: 0;
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
/* Search and the type filter, the band's second row. */
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
/* Spans the row, the way the dashboards filter bar does. The input's own
wrapper is `width: 100%`, so the basis has to be 0 or `auto` resolves to the
full row and wraps the type filter onto the next line. */
.searchInput {
flex: 1 1 0;
min-width: 220px;
}
.kindSelect {
/* the trigger sizes itself to 100%, so the flex item has to be bounded */
flex: 0 0 180px;
width: 180px;
max-width: 180px;
}
/* The options panel is rendered inline (withPortal={false}) so it can be told to
match the trigger; Radix publishes that width at the popper layer. */
.filtersRow :global([data-slot='select-content']) {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
.commandHeader > button {
flex: 0 0 auto;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsHeader {
display: block;
text-align: right;
}
/* Left on the table's own defaults, the way Infrastructure Monitoring uses it:
transparent cells over the page background rather than a raised slab. */
.tableContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -0,0 +1,253 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import {
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
SEARCH_KEY,
} from './constants';
import { useChannelDelete } from './hooks/useChannelDelete';
import { useChannelFormView } from './hooks/useChannelFormView';
import { useChannelsData } from './hooks/useChannelsData';
import styles from './NotificationChannels.module.scss';
import { getChannelColumns } from './table.config';
import type { Channel } from './types';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
function NotificationChannels(): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formView = useChannelFormView();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'notification-channels',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
const resetPageOnSearch = useCallback((): void => {
setPage(1);
}, [setPage]);
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(SEARCH_KEY, { onDebouncedChange: resetPageOnSearch });
const [kind, setKind] = useQueryState(
KIND_KEY,
parseAsString.withDefault(KIND_FILTER_ALL),
);
const handleKindChange = useCallback(
(value: string | string[]): void => {
const next = value as string;
void setKind(next === KIND_FILTER_ALL ? null : next);
setPage(1);
},
[setKind, setPage],
);
const { channels, total, isFetching, isError, refetch } = useChannelsData({
search: debouncedSearch,
kind,
orderBy,
page,
limit,
});
const deletion = useChannelDelete();
const [jsonChannel, setJsonChannel] = useState<Channel | null>(null);
useEffect(() => {
void logEvent('Alert Channel: Channel list page visited', {});
}, []);
const handleEdit = useCallback(
(channel: Channel, options?: { newTab?: boolean }): void => {
formView.openEdit(channel.id, options);
},
[formView],
);
const handleRowClick = useCallback(
(channel: Channel): void => formView.openEdit(channel.id),
[formView],
);
const handleRowClickNewTab = useCallback(
(channel: Channel): void => formView.openEdit(channel.id, { newTab: true }),
[formView],
);
const columns = useMemo(
() => getChannelColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const columnsWithActions = useMemo(
() => [
...columns,
{
id: 'actions',
header: (): JSX.Element => (
<span className={styles.actionsHeader}>Actions</span>
),
accessorKey: 'id',
width: { fixed: '96px', ignoreLastColumnFill: true },
enableSort: false,
enableResize: false,
enableRemove: false,
enableMove: false,
pin: 'right' as const,
cell: ({ row }: { row: Channel }): JSX.Element => (
<ChannelActionsMenu
channel={row}
onOpen={handleEdit}
onDelete={deletion.request}
onViewJson={setJsonChannel}
/>
),
},
],
[columns, handleEdit, deletion.request],
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
clearSearch();
void setKind(null);
}, [clearSearch, setKind]);
return (
<div className={styles.container}>
<header className={styles.headerBand}>
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All notification channels
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<Button
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</Button>
</div>
<div className={styles.filtersRow}>
<Input
containerClassName={styles.searchInput}
placeholder="Search by channel name"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="channels-search"
/>
<SelectSimple
className={styles.kindSelect}
items={KIND_FILTER_OPTIONS}
value={kind}
onChange={handleKindChange}
withPortal={false}
testId="channels-kind-filter"
/>
</div>
</header>
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState
title="Failed to load notification channels"
onRefresh={refetch}
/>
) : isEmpty && hasActiveFilters ? (
<NoResultsEmptyState
title="No matching channels"
subtitle="No notification channels match your search. Try adjusting your search criteria."
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : (
<TanStackTable<Channel>
data={channels}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id}
getItemKey={(row): string => row.id}
columnStorageKey="notification-channels-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={1}
/>
)}
</div>
{jsonChannel && (
<ChannelJsonDialog
channelId={jsonChannel.id}
channelName={jsonChannel.displayName}
onClose={(): void => setJsonChannel(null)}
/>
)}
{deletion.contextHolder}
</div>
);
}
export default NotificationChannels;

View File

@@ -0,0 +1,98 @@
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { renderChannels } from './renderChannels';
const FIND = { timeout: 5000 };
const TEST_TIMEOUT = 20000;
describe('Notification channels list', () => {
beforeEach(() => {
// search, filter and page live in the url, so one test's filter would
// otherwise still be applied in the next
window.history.replaceState({}, '', '/');
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it(
'heads the page with the channel count',
async () => {
renderChannels();
await expect(
screen.findByText('All notification channels', {}, FIND),
).resolves.toBeInTheDocument();
// the count comes from the API's total, not the loaded page
await expect(screen.findByText('2', {}, FIND)).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'lists every channel with its type',
async () => {
renderChannels();
await expect(
screen.findByText('Dummy-Channel', {}, FIND),
).resolves.toBeInTheDocument();
expect(screen.getByText('Oncall PagerDuty')).toBeInTheDocument();
// the type reads as plain text, no colour coding
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('PagerDuty')).toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'filters server-side as the user types',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
await userEvent.type(screen.getByTestId('channels-search'), 'oncall');
await waitFor(() => {
expect(screen.queryByText('Dummy-Channel')).not.toBeInTheDocument();
}, FIND);
await expect(
screen.findByText('Oncall PagerDuty', {}, FIND),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'asks for confirmation before deleting, from the row menu',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
// the table paints a loading layer over the rows until the fetch settles
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(screen.getByTestId('channel-actions-3'));
// Radix renders the menu into a portal once the trigger is activated
await screen.findByRole('menu', {}, FIND);
// the menu lives inside the row's clickable cell, so opening it must not
// also fire the row click and route to the edit screen
expect(window.location.pathname).not.toContain('/channels/edit/');
await user.click(
await screen.findByRole('menuitem', { name: 'Delete' }, FIND),
);
// the shared destructive confirmation, the one dashboards also use. The
// name sits in its own element, so the sentence spans several nodes.
const confirmation = await screen.findByRole('dialog', {}, FIND);
expect(confirmation.textContent).toContain(
'Are you sure you want to delete the Dummy-Channel channel?',
);
},
TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,54 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import {
channelToJson,
hasSecrets,
SECRET_PLACEHOLDER,
} from '../utils/channelJson';
const slackChannel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/SECRET',
channel: '#alerts',
sendResolved: true,
},
},
} as AlertmanagertypesGettableNotificationChannelDTO;
describe('channelToJson', () => {
it('hides the credential the API returns in the clear', () => {
const json = JSON.parse(channelToJson(slackChannel, false));
expect(json.config.spec.apiUrl).toBe(SECRET_PLACEHOLDER);
expect(json.config.spec.channel).toBe('#alerts');
expect(channelToJson(slackChannel, false)).not.toContain('SECRET');
});
it('reveals it only when asked', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json.config.spec.apiUrl).toBe(
'https://hooks.slack.com/services/T/B/SECRET',
);
});
it('leaves the timestamps and id out, so the JSON can be reused', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json).not.toHaveProperty('id');
expect(json).not.toHaveProperty('createdAt');
expect(json.name).toBe('prod-alerts');
});
it('knows which kinds carry a credential', () => {
expect(hasSecrets('slack')).toBe(true);
expect(hasSecrets('email')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import NotificationChannels from '../NotificationChannels';
/** The list is virtualized, so jsdom needs a viewport for rows to mount. */
export function renderChannels(): ReturnType<typeof render> {
return render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 800, itemHeight: 46 }}>
<NotificationChannels />
</VirtuosoMockContext.Provider>,
);
}

View File

@@ -0,0 +1,5 @@
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
}

View File

@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
Braces,
Ellipsis,
Expand,
Link2,
SquareArrowOutUpRight,
Trash2,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import ROUTES from 'constants/routes';
import { getAbsoluteUrl } from 'utils/basePath';
import type { Channel } from '../../types';
import styles from './ChannelActionsMenu.module.scss';
interface ChannelActionsMenuProps {
channel: Channel;
onOpen: (channel: Channel, options?: { newTab?: boolean }) => void;
onDelete: (channel: Channel) => void;
onViewJson: (channel: Channel) => void;
}
function ChannelActionsMenu({
channel,
onOpen,
onDelete,
onViewJson,
}: ChannelActionsMenuProps): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const menuItems = useMemo<DropdownItemType[]>(
() => [
{
type: 'item',
value: 'open',
label: 'View',
prefix: <Expand size={14} />,
onClick: (): void => onOpen(channel),
},
{
type: 'item',
value: 'open-new-tab',
label: 'Open in New Tab',
prefix: <SquareArrowOutUpRight size={14} />,
onClick: (): void => onOpen(channel, { newTab: true }),
},
{
type: 'item',
value: 'copy-link',
label: 'Copy Link',
prefix: <Link2 size={14} />,
onClick: (): void => {
setCopy(
getAbsoluteUrl(
generatePath(ROUTES.CHANNELS_EDIT, { channelId: channel.id }),
),
);
toast.success('Link copied to clipboard');
},
},
{
type: 'item',
value: 'view-json',
label: 'View JSON',
prefix: <Braces size={14} />,
onClick: (): void => onViewJson(channel),
},
{ type: 'separator', value: 'before-delete' },
{
type: 'item',
value: 'delete',
label: 'Delete',
prefix: <Trash2 size={14} />,
danger: true,
onClick: (): void => onDelete(channel),
},
],
[channel, onOpen, onDelete, onViewJson, setCopy],
);
return (
// the row itself opens the channel, so the actions keep their clicks
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
variant="outlined"
color="secondary"
size="sm"
icon
testId={`channel-actions-${channel.id}`}
aria-label={`Actions for ${channel.displayName}`}
>
<Ellipsis size={16} />
</Button>
</Dropdown>
</div>
);
}
export default ChannelActionsMenu;

View File

@@ -0,0 +1,26 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
height: 100%;
min-height: 420px;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.spacer {
flex: 1 1 auto;
}
.editor {
flex: 1 1 auto;
min-height: 360px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}

View File

@@ -0,0 +1,115 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor from '@monaco-editor/react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import Spinner from 'components/Spinner';
import { READONLY_EDITOR_OPTIONS } from 'container/RolesSettings/monaco.config';
import {
defineJsonEditorTheme,
JSON_EDITOR_THEME,
} from 'lib/monaco/jsonEditorTheme';
import { channelToJson, hasSecrets } from '../../utils/channelJson';
import styles from './ChannelJsonDialog.module.scss';
import ChannelJsonToolbar from './ChannelJsonToolbar';
interface ChannelJsonDialogProps {
channelId: string;
channelName: string;
onClose: () => void;
}
function ChannelJsonDialog({
channelId,
channelName,
onClose,
}: ChannelJsonDialogProps): JSX.Element {
const [copyState, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const [showSecrets, setShowSecrets] = useState(false);
const { data, isLoading, refetch } = useGetNotificationChannel({
id: channelId,
});
const channel = data?.data;
const json = useMemo(
() => (channel ? channelToJson(channel, showSecrets) : ''),
[channel, showSecrets],
);
useEffect(() => {
if (copyState.value) {
setCopied(true);
const timer = setTimeout(() => setCopied(false), 1500);
return (): void => clearTimeout(timer);
}
return undefined;
}, [copyState]);
const handleCopy = useCallback(
(): void => copyToClipboard(json),
[copyToClipboard, json],
);
const handleRefetch = useCallback((): void => {
void refetch();
}, [refetch]);
const handleToggleSecrets = useCallback(
(): void => setShowSecrets((value) => !value),
[],
);
const kindHasSecrets = channel ? hasSecrets(channel.config.kind) : false;
return (
<DrawerWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={`${channelName} / JSON`}
width="wide"
testId="channel-json-dialog"
>
{isLoading ? (
<Spinner tip="Loading channel..." />
) : !channel ? (
<ErrorEmptyState title="Failed to load channel" onRefresh={handleRefetch} />
) : (
<div className={styles.body}>
<ChannelJsonToolbar
kindHasSecrets={kindHasSecrets}
showSecrets={showSecrets}
onToggleSecrets={handleToggleSecrets}
copied={copied}
onCopy={handleCopy}
/>
<div className={styles.editor}>
<MEditor
value={json}
language="json"
options={READONLY_EDITOR_OPTIONS}
height="100%"
theme="vs-dark"
// the same theme the dashboard JSON drawer uses: it reads the
// page's own tokens, so it follows light and dark
onMount={(editor, monaco): void => {
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
monaco.editor.setTheme(JSON_EDITOR_THEME);
}}
/>
</div>
</div>
)}
</DrawerWrapper>
);
}
export default ChannelJsonDialog;

View File

@@ -0,0 +1,63 @@
import { Color } from '@signozhq/design-tokens';
import { Check, Copy, Eye, EyeOff } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './ChannelJsonDialog.module.scss';
interface ChannelJsonToolbarProps {
kindHasSecrets: boolean;
showSecrets: boolean;
onToggleSecrets: () => void;
copied: boolean;
onCopy: () => void;
}
function ChannelJsonToolbar({
kindHasSecrets,
showSecrets,
onToggleSecrets,
copied,
onCopy,
}: ChannelJsonToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
{kindHasSecrets && (
<Typography.Text color="muted" size="sm">
{showSecrets ? 'Credentials are visible.' : 'Credentials are hidden.'}
</Typography.Text>
)}
<span className={styles.spacer} />
{kindHasSecrets && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={showSecrets ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleSecrets}
testId="channel-json-toggle-secrets"
>
{showSecrets ? 'Hide credentials' : 'Show credentials'}
</Button>
)}
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={
copied ? (
<Check size={14} color={Color.BG_FOREST_400} />
) : (
<Copy size={14} />
)
}
onClick={onCopy}
testId="channel-json-copy"
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
);
}
export default ChannelJsonToolbar;

View File

@@ -0,0 +1,35 @@
import { AlertmanagertypesChannelKindDTO } from 'api/generated/services/sigNoz.schemas';
export const SEARCH_KEY = 'search';
export const KIND_KEY = 'kind';
export const PAGE_KEY = 'page';
export const PAGE_SIZE = 20;
export const SEARCH_DEBOUNCE_MS = 300;
export const CHANNEL_KIND_LABEL: Record<
AlertmanagertypesChannelKindDTO,
string
> = {
[AlertmanagertypesChannelKindDTO.slack]: 'Slack',
[AlertmanagertypesChannelKindDTO.email]: 'Email',
[AlertmanagertypesChannelKindDTO.webhook]: 'Webhook',
[AlertmanagertypesChannelKindDTO.pagerduty]: 'PagerDuty',
[AlertmanagertypesChannelKindDTO.opsgenie]: 'Opsgenie',
[AlertmanagertypesChannelKindDTO.msteams]: 'MS Teams',
[AlertmanagertypesChannelKindDTO.googlechat]: 'Google Chat',
[AlertmanagertypesChannelKindDTO.jira]: 'Jira',
[AlertmanagertypesChannelKindDTO.jsmops]: 'JSM Ops',
[AlertmanagertypesChannelKindDTO.incidentio]: 'incident.io',
[AlertmanagertypesChannelKindDTO.telegram]: 'Telegram',
};
export const KIND_FILTER_ALL = 'all';
export const KIND_FILTER_OPTIONS = [
{ value: KIND_FILTER_ALL, label: 'All types' },
...Object.entries(CHANNEL_KIND_LABEL).map(([value, label]) => ({
value,
label,
})),
];

View File

@@ -0,0 +1,57 @@
import { useQueryClient } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import {
invalidateListNotificationChannels,
useDeleteNotificationChannel,
} from 'api/generated/services/channels';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import styles from 'components/DeleteConfirmModal/DeleteConfirmModal.module.scss';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ReactNode } from 'react';
import APIError from 'types/api/error';
import type { Channel } from '../types';
export interface ChannelDeleteState {
/** Must be rendered by the caller for the confirmation to appear. */
contextHolder: ReactNode;
request: (channel: Channel) => void;
}
/**
* Deleting a channel goes through the shared destructive confirmation, the same
* one dashboards and saved views use, so the wording and the danger button match.
*/
export function useChannelDelete(onDeleted?: () => void): ChannelDeleteState {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { contextHolder, confirmDelete } = useDeleteConfirm();
const { mutateAsync } = useDeleteNotificationChannel();
const request = (channel: Channel): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the
<Typography.Text className={styles.name}>
{' '}
{channel.displayName}{' '}
</Typography.Text>
channel?
</Typography.Title>
),
// Returning the promise keeps Delete loading until the request settles.
onConfirm: async (): Promise<void> => {
try {
await mutateAsync({ pathParams: { id: channel.id } });
await invalidateListNotificationChannels(queryClient);
onDeleted?.();
} catch (error) {
showErrorModal(error as APIError);
}
},
});
};
return { contextHolder, request };
}

View File

@@ -0,0 +1,28 @@
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: () => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
/** Create and edit both render on their own route. */
export function useChannelFormView(): ChannelFormView {
const openEdit = (channelId: string, options?: { newTab?: boolean }): void => {
const path = generatePath(ROUTES.CHANNELS_EDIT, { channelId });
if (options?.newTab) {
openInNewTab(path);
return;
}
history.push(path);
};
return {
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openEdit,
};
}

View File

@@ -0,0 +1,75 @@
import { useListNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView';
import { KIND_FILTER_ALL } from '../constants';
import type { Channel } from '../types';
/** Table column ids the list API can order by. */
const SORT_BY_COLUMN: Record<string, AlertmanagertypesChannelListSortDTO> = {
name: AlertmanagertypesChannelListSortDTO.name,
createdAt: AlertmanagertypesChannelListSortDTO.created_at,
updatedAt: AlertmanagertypesChannelListSortDTO.updated_at,
};
interface UseChannelsDataArgs {
search: string;
kind: string;
orderBy: SortState | null;
page: number;
limit: number;
enabled?: boolean;
}
export interface ChannelsData {
channels: Channel[];
total: number;
isFetching: boolean;
isError: boolean;
refetch: () => void;
}
/**
* Search, kind filter, sort and paging all run on the API, so the table shows
* one page at a time rather than filtering a full download in the browser.
*/
export function useChannelsData({
search,
kind,
orderBy,
page,
limit,
enabled = true,
}: UseChannelsDataArgs): ChannelsData {
const sort = orderBy ? SORT_BY_COLUMN[orderBy.columnName] : undefined;
const { data, isFetching, isError, refetch } = useListNotificationChannels(
{
limit,
offset: (page - 1) * limit,
...(sort
? {
sort,
order: orderBy?.order as AlertmanagertypesChannelListOrderDTO,
}
: {}),
...(search ? { query: search } : {}),
...(kind !== KIND_FILTER_ALL
? { kind: kind as AlertmanagertypesChannelKindDTO }
: {}),
},
{ query: { enabled, keepPreviousData: true } },
);
return {
channels: data?.data?.channels ?? [],
total: data?.data?.total ?? 0,
isFetching,
isError,
refetch,
};
}

View File

@@ -0,0 +1,88 @@
import type { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { CHANNEL_KIND_LABEL } from './constants';
import styles from './table.module.scss';
import type { Channel } from './types';
import { toRelativeTime } from './utils/relativeTime';
export function getChannelColumns(
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
): TableColumnDef<Channel>[] {
const timestampCell = (row: Channel, value: string): JSX.Element => {
if (!value) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<TanStackTable.HoverTooltip
rowId={row.id}
title={formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
>
{/* A plain inline-block hugs the text. TanStackTable.Text is a
`-webkit-box` for line clamping, which fills the cell and leaves
the arrow pointing at the cell's middle instead of the words. */}
<span className={styles.timeText}>{toRelativeTime(value)}</span>
</TanStackTable.HoverTooltip>
);
};
return [
{
id: 'name',
header: 'Channel',
accessorKey: 'displayName',
width: { default: '100%', min: '220px' },
enableSort: true,
enableResize: true,
enableRemove: false,
enableMove: false,
cell: ({ row }): JSX.Element => (
<TanStackTable.Text
title={row.displayName}
data-testid={`channel-row-${row.id}-name`}
>
{row.displayName}
</TanStackTable.Text>
),
},
{
id: 'kind',
header: 'Type',
accessorKey: 'kind',
width: { default: '150px', min: '110px' },
enableSort: false,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => (
<TanStackTable.Text data-testid={`channel-row-${row.id}-kind`}>
{CHANNEL_KIND_LABEL[row.kind]}
</TanStackTable.Text>
),
},
{
id: 'createdAt',
header: 'Created At',
accessorKey: 'createdAt',
width: { default: '150px', min: '120px' },
enableSort: true,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => timestampCell(row, row.createdAt),
},
{
id: 'updatedAt',
header: 'Updated At',
accessorKey: 'updatedAt',
width: { default: '150px', min: '120px' },
enableSort: true,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => timestampCell(row, row.updatedAt),
},
];
}

View File

@@ -0,0 +1,7 @@
.timeText {
display: inline-block;
white-space: nowrap;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: inherit;
}

View File

@@ -0,0 +1,4 @@
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
/** A channel as the list API returns it: identity and kind, but no config. */
export type Channel = AlertmanagertypesListedNotificationChannelDTO;

View File

@@ -0,0 +1,50 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
export const SECRET_PLACEHOLDER = '••••••••';
/**
* The spec fields the API marks `format: password`. A fetch by ID returns them
* in the clear, so the JSON view hides them until asked.
*/
const SECRET_FIELDS_BY_KIND: Record<string, readonly string[]> = {
slack: ['apiUrl'],
webhook: ['url', 'password', 'bearerToken'],
pagerduty: ['routingKey'],
opsgenie: ['apiKey'],
msteams: ['webhookUrl'],
googlechat: ['webhookUrl'],
jira: ['apiToken'],
jsmops: ['apiKey'],
incidentio: ['token'],
};
export function channelToJson(
channel: AlertmanagertypesGettableNotificationChannelDTO,
showSecrets: boolean,
): string {
const { kind, spec } = channel.config;
const secrets = SECRET_FIELDS_BY_KIND[kind] ?? [];
const safeSpec = showSecrets
? spec
: Object.fromEntries(
Object.entries(spec).map(([key, value]) => [
key,
secrets.includes(key) && value ? SECRET_PLACEHOLDER : value,
]),
);
return JSON.stringify(
{
name: channel.name,
displayName: channel.displayName,
config: { kind, spec: safeSpec },
},
null,
2,
);
}
export function hasSecrets(kind: string): boolean {
return (SECRET_FIELDS_BY_KIND[kind] ?? []).length > 0;
}

View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTimePlugin);
/** "2 months ago". The exact timestamp stays available on hover. */
export function toRelativeTime(value: string | undefined): string {
const parsed = value ? dayjs(value) : null;
return parsed?.isValid() ? parsed.fromNow() : '-';
}

View File

@@ -17,7 +17,7 @@ import {
import {
CHANNEL_MAX,
channelNames,
channelsResponse,
notificationChannelsResponse,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
@@ -118,10 +118,10 @@ export const routingPoliciesMocks = defineStoryMocks({
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v1/channels', (_req, res, ctx) =>
rest.get('http://localhost/api/v2/notification_channels', (_req, res, ctx) =>
values.channelsState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(ctx.json(channelsResponse(values.channels))),
: res(ctx.json(notificationChannelsResponse(values.channels))),
),
],
config: () => ({

View File

@@ -4,7 +4,6 @@ import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import AllAlertChannels from 'container/AllAlertChannels';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
@@ -16,6 +15,8 @@ import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
import NotificationChannels from './NotificationChannels/NotificationChannels';
import { AlertListSubTabs, AlertListTabs } from './types';
import './AlertList.styles.scss';
@@ -104,7 +105,7 @@ function AllAlertList(): JSX.Element {
<div className="alert-rules-container">
{isChannelsNew && <ChannelsNew />}
{isChannelsEdit && <ChannelsEdit />}
{!isChannelDetails && <AllAlertChannels />}
{!isChannelDetails && <NotificationChannels />}
</div>
),
},

View File

@@ -26,6 +26,9 @@ import {
type RuletypesAlertCompositeQueryDTO,
type RuletypesRuleConditionDTO,
type RuletypesRuleDTO,
type AlertmanagertypesChannelConfigDTO,
type AlertmanagertypesGettableNotificationChannelDTO,
type AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import type { Channels } from 'types/api/channels/getAll';
@@ -704,6 +707,22 @@ const buildChannel = (index: number): Channels => {
};
};
const buildListedChannel = (
index: number,
): AlertmanagertypesListedNotificationChannelDTO => {
const seed = CHANNEL_SEEDS[index % CHANNEL_SEEDS.length];
return {
id: String(index + 1),
name: seed.name,
displayName: seed.name,
kind: seed.type as AlertmanagertypesListedNotificationChannelDTO['kind'],
createdAt: ago((index + 10) * DAY),
updatedAt: ago((index + 1) * DAY),
};
};
/** The v2 list, which is what the channel pickers read. */
export const channelsResponse = (
count: number,
): { status: string; data: Channels[] } => ({
@@ -711,10 +730,145 @@ export const channelsResponse = (
data: Array.from({ length: count }, (_unused, index) => buildChannel(index)),
});
/** The v2 list, which is what the redesigned channels list reads. */
export const notificationChannelsResponse = (
count: number,
): {
status: string;
data: {
channels: AlertmanagertypesListedNotificationChannelDTO[];
total: number;
};
} => {
const channels = Array.from({ length: count }, (_unused, index) =>
buildListedChannel(index),
);
return { status: 'success', data: { channels, total: channels.length } };
};
/**
* The v2 list as the API answers it: search, kind and paging are applied on the
* server, so the story has to narrow the rows rather than hand back all of them.
*/
export const notificationChannelsPage = (
count: number,
params: URLSearchParams,
): {
status: string;
data: {
channels: AlertmanagertypesListedNotificationChannelDTO[];
total: number;
};
} => {
const query = params.get('query')?.trim().toLowerCase() ?? '';
const kind = params.get('kind') ?? '';
const limit = Number(params.get('limit')) || CHANNEL_MAX;
const offset = Number(params.get('offset')) || 0;
const matched = Array.from({ length: count }, (_unused, index) =>
buildListedChannel(index),
).filter(
(channel) =>
(!query || channel.displayName.toLowerCase().includes(query)) &&
(!kind || channel.kind === kind),
);
return {
status: 'success',
data: {
channels: matched.slice(offset, offset + limit),
total: matched.length,
},
};
};
/**
* One listed row fetched on its own, as the JSON dialog does. Indexed the same
* way the list builds its rows, so a row's id resolves to the kind it displays.
*/
export const notificationChannelByRowId = (
id: string,
): ReturnType<typeof notificationChannelResponse> => {
const index = (Number(id) - 1 + CHANNEL_SEEDS.length) % CHANNEL_SEEDS.length;
return notificationChannelResponse(id, CHANNEL_SEEDS[index].type);
};
/** Channel names the alert form and the routing policies pick from. */
export const channelNames = (count: number): string[] =>
channelsResponse(count).data.map((channel) => channel.name);
/**
* The v2 spec each kind is fetched with, so the edit form renders populated
* fields. Keys mirror the generated channel spec schemas.
*/
const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T000/B000/story-token',
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
sendResolved: true,
},
webhook: {
url: 'https://example.com/story-hook',
username: 'story-user',
sendResolved: true,
},
pagerduty: {
routingKey: 'story-routing-key',
description: '{{ .CommonLabels.alertname }}',
severity: 'critical',
sendResolved: true,
},
opsgenie: {
apiKey: 'story-opsgenie-key',
message: '{{ .CommonLabels.alertname }}',
description: 'Story channel',
sendResolved: true,
},
email: {
to: 'oncall@story.signoz.io',
html: '<p>{{ .CommonLabels.alertname }}</p>',
sendResolved: true,
},
msteams: {
webhookUrl: 'https://story.webhook.office.com/webhookb2/story',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: 'Story channel',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/STORY/messages',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: 'Story channel',
sendResolved: true,
},
jira: {
site: 'https://story.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'story@signoz.io',
apiToken: 'story-jira-token',
summary: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
jsmops: {
apiKey: 'story-jsm-key',
message: '{{ .CommonLabels.alertname }}',
description: 'Story channel',
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/story',
token: 'story-incidentio-token',
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
};
/** A single channel as the v2 API returns it, which the edit screen reads. */
export const channelResponse = (
id: string,
type: ChannelType,
@@ -727,6 +881,36 @@ export const channelResponse = (
};
};
/** The v2 fetch by id, which is what the redesigned screens read. */
export const notificationChannelResponse = (
id: string,
type: ChannelType,
): {
status: string;
data: AlertmanagertypesGettableNotificationChannelDTO;
} => {
const index = Math.max(
CHANNEL_SEEDS.findIndex((seed) => seed.type === type),
0,
);
const seed = CHANNEL_SEEDS[index];
return {
status: 'success',
data: {
id,
name: seed.name,
displayName: seed.name,
createdAt: ago((index + 10) * DAY),
updatedAt: ago((index + 1) * DAY),
config: {
kind: type,
spec: CHANNEL_SPECS[type],
} as unknown as AlertmanagertypesChannelConfigDTO,
},
};
};
export const CHANNEL_ACTION_OUTCOMES = ['succeeds', 'fails'] as const;
export type ChannelActionOutcome = (typeof CHANNEL_ACTION_OUTCOMES)[number];

View File

@@ -21,7 +21,7 @@ import {
alertPreviewSeries,
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
import {
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
} from '../../AlertList/stories/__story_mockdata__/alerts';
@@ -127,8 +127,8 @@ export const createAlertMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(

View File

@@ -12,7 +12,10 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import {
defineJsonEditorTheme,
JSON_EDITOR_THEME,
} from 'lib/monaco/jsonEditorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';

View File

@@ -39,12 +39,14 @@ export function useContextLinkVariables(): VariableItem[] {
return useMemo(
() => [
...GLOBAL_TIMESTAMP_VARIABLES,
...fieldVariableNames.map(
(name): VariableItem => ({ name, source: 'Query variable' }),
),
...dashboardVariableNames.map(
(name): VariableItem => ({ name, source: 'Dashboard variable' }),
),
...fieldVariableNames.map((name): VariableItem => ({
name,
source: 'Query variable',
})),
...dashboardVariableNames.map((name): VariableItem => ({
name,
source: 'Dashboard variable',
})),
],
[fieldVariableNames, dashboardVariableNames],
);

View File

@@ -54,15 +54,13 @@ export function buildDownloadMenuItem({
const items = DOWNLOAD_FORMAT_OPTIONS.filter(
({ format }) => supported[format],
).map(
({ format, label, icon }): DropdownActionItemType => ({
type: 'item',
value: `download-${format}`,
label,
prefix: icon,
onClick: (): void => onDownload(format),
}),
);
).map(({ format, label, icon }): DropdownActionItemType => ({
type: 'item',
value: `download-${format}`,
label,
prefix: icon,
onClick: (): void => onDownload(format),
}));
if (items.length === 0) {
return null;

View File

@@ -338,10 +338,12 @@ export const dashboardMocks = defineStoryMocks({
// The header reads the public link on every load, so it answers even while
// the panels are held in the loading or failed state.
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
values.published
? res(ctx.status(200), ctx.json(publicMeta()))
: res(ctx.status(404), ctx.json(NOT_PUBLIC)),
rest.get(
'http://localhost/api/v1/dashboards/:id/public',
(_req, res, ctx) =>
values.published
? res(ctx.status(200), ctx.json(publicMeta()))
: res(ctx.status(404), ctx.json(NOT_PUBLIC)),
),
rest.post('http://localhost/api/v1/dashboards/:id/public', (req, res, ctx) =>

View File

@@ -20,7 +20,7 @@ import {
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
type AlertSchema,
} from '../../AlertList/stories/__story_mockdata__/alerts';
@@ -77,8 +77,8 @@ export const editRulesMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(

View File

@@ -41,12 +41,10 @@ export const liveLogsQuery = (): Query => {
...seed,
builder: {
...seed.builder,
queryData: seed.builder.queryData.map(
(data): IBuilderQuery => ({
...data,
filter: { expression: LIVE_TAIL_FILTER },
}),
),
queryData: seed.builder.queryData.map((data): IBuilderQuery => ({
...data,
filter: { expression: LIVE_TAIL_FILTER },
})),
},
};
};

View File

@@ -1,47 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
logsSavedViewsResponse,
SAVED_VIEW_MAX,
SAVED_VIEWS_PAGE_SIZE,
savedViewWriteResponse,
} from './__story_mockdata__/savedViews';
const LIST = 'Views · list';
export const savedViewsMocks = defineStoryMocks({
controls: {
views: countControl('Saved views', {
group: LIST,
description: `The views the org saved for logs. Past ${SAVED_VIEWS_PAGE_SIZE} the table paginates.`,
value: 4,
max: SAVED_VIEW_MAX,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/explorer/views',
response.json(() => logsSavedViewsResponse(values.views)),
),
rest.put(
'http://localhost/api/v1/explorer/views/:viewId',
response.json(savedViewWriteResponse),
),
rest.delete(
'http://localhost/api/v1/explorer/views/:viewId',
response.json(savedViewWriteResponse),
),
],
config: () => ({ route: ROUTES.LOGS_SAVE_VIEWS }),
});

View File

@@ -1,88 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { savedViewsMocks } from './SavedViews.stories.mocks';
import LogsModulePage from '../../LogsModulePage';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const pageStory = storyMocks(savedViewsMocks, { layout: 'app' });
/**
* Saved views on the logs explorer, and the actions the legacy editor role gates.
*
* Route: `/logs/saved-views`.
*/
const meta = {
title: 'Pages/Logs/Saved Views',
tags: ['role-gated', 'play'],
component: LogsModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** Row actions are per-view icons, so a story that opens one picks the first. */
const openFirstAction = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const [action] = await within(canvasElement).findAllByTestId(
testId,
undefined,
untilLoaded,
);
await userEvent.click(action);
};
/**
* The views tab: every view the org saved for logs, who saved it and when, with
* the colour the view picker shows it under.
*/
export const Default: Story = {};
/** A workspace where nobody has saved a view yet. */
export const NoViews: Story = {
args: { views: 0 },
};
/** Enough views to paginate, which is the only place the second page shows up. */
export const Paginated: Story = {
args: { views: 8 },
};
/** Renaming a view: its label and the colour the picker shows it under. */
export const EditView: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstAction(canvasElement, 'edit-view');
await screen.findByText('Edit view details', undefined, untilLoaded);
},
};
/** The confirmation a view has to pass before it is deleted. */
export const DeleteView: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstAction(canvasElement, 'delete-view');
await screen.findByText(
/are you sure you want to delete/i,
undefined,
untilLoaded,
);
},
};
/** A viewer: the views are listed, none of them editable. */
export const ViewerAccess: Story = {
args: { access: 'viewer' },
};

View File

@@ -38,12 +38,10 @@ export function getTableData(data: DropRateResponse[]): RowData[] {
}
const tableData: RowData[] =
data?.map(
(row: DropRateResponse, index: number): RowData => ({
...(row.data as any), // todo-sagar
key: index,
}),
) || [];
data?.map((row: DropRateResponse, index: number): RowData => ({
...(row.data as any), // todo-sagar
key: index,
})) || [];
return tableData;
}

View File

@@ -21,14 +21,12 @@ export function getTableDataForProducerLatencyOverview(
// Merge the data from both tables
const mergedTableData: RowData[] =
firstTableData.map(
(row, index): RowData => ({
...row.data,
byte_rate:
byteRateMap.get(`${row.data.service_name}--${row.data.topic}`) || 0,
key: index,
}),
) || [];
firstTableData.map((row, index): RowData => ({
...row.data,
byte_rate:
byteRateMap.get(`${row.data.service_name}--${row.data.topic}`) || 0,
key: index,
})) || [];
return mergedTableData;
}

View File

@@ -108,12 +108,10 @@ export function getTableData(
}
const tableData: RowData[] =
data?.result?.[0]?.table?.rows?.map(
(row, index: number): RowData => ({
...row.data,
key: index,
}),
) || [];
data?.result?.[0]?.table?.rows?.map((row, index: number): RowData => ({
...row.data,
key: index,
})) || [];
return tableData;
}

View File

@@ -64,6 +64,7 @@ import {
metricTemporalityOf,
metricTypeOf,
metricsSavedViewsResponse,
metricsSavedViewsV2Response,
metricsStatsResponse,
metricsTreemapResponse,
REDUCTION_RULE_MAX,
@@ -517,6 +518,11 @@ export const metricsMocks = defineStoryMocks({
response.json(() => metricsSavedViewsResponse(values.savedViews)),
),
rest.get(
'http://localhost/api/v2/saved_views',
response.json(() => metricsSavedViewsV2Response(values.savedViews)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {

View File

@@ -16,6 +16,7 @@ import type {
InspectMetrics200,
ListMetricReductionRules200,
ListMetrics200,
ListSavedViews200,
MetricreductionruletypesGettableReductionRuleDTO,
MetricsexplorertypesStatDTO,
MetricsexplorertypesTreemapEntryDTO,
@@ -24,7 +25,12 @@ import {
MetricreductionruletypesMatchTypeDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
import type { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
@@ -785,6 +791,33 @@ export const metricsSavedViewsResponse = (count: number): AllViewsProps => ({
})),
});
export const metricsSavedViewsV2Response = (
count: number,
): ListSavedViews200 => ({
status: 'success',
data: VIEW_NAMES.slice(0, count).map((displayName, index) => ({
id: `storybook-metrics-view-${index + 1}`,
name: `storybook-metrics-view-${index + 1}`,
source: SavedviewtypesSourceDTO.metrics,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdAt: '2026-08-20T09:00:00Z',
createdBy: 'ada@signoz.io',
updatedAt: '2026-08-20T09:00:00Z',
updatedBy: 'ada@signoz.io',
spec: {
displayName,
panelType: SavedviewtypesPanelTypeDTO.graph,
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
queries: [
{
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
spec: { name: 'A', signal: MetricsSignal.metrics },
},
],
},
})),
});
const FIELD_KEYS = [
'service.name',
'deployment.environment',

View File

@@ -201,30 +201,28 @@ export default function Support(): JSX.Element {
</div>
<div className="support-channels">
{supportChannels.map(
(channel): JSX.Element => (
<Card className="support-channel" key={channel.key}>
<div className="support-channel-content">
<Title truncate={1} level={5} className="support-channel-title">
{channel.icon}
{channel.name}{' '}
</Title>
<Text> {channel.title} </Text>
</div>
{supportChannels.map((channel): JSX.Element => (
<Card className="support-channel" key={channel.key}>
<div className="support-channel-content">
<Title truncate={1} level={5} className="support-channel-title">
{channel.icon}
{channel.name}{' '}
</Title>
<Text> {channel.title} </Text>
</div>
<div className="support-channel-action">
<Button
className="periscope-btn secondary support-channel-btn"
type="default"
onClick={(): void => handleChannelClick(channel)}
>
<Text truncate={1}>{channel.btnText} </Text>
{channel.isExternal && <ArrowUpRight size={14} />}
</Button>
</div>
</Card>
),
)}
<div className="support-channel-action">
<Button
className="periscope-btn secondary support-channel-btn"
type="default"
onClick={(): void => handleChannelClick(channel)}
>
<Text truncate={1}>{channel.btnText} </Text>
{channel.isExternal && <ArrowUpRight size={14} />}
</Button>
</div>
</Card>
))}
</div>
</div>

View File

@@ -144,21 +144,19 @@ const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
['503', 'GET /status/unavailable'],
['504', 'GET /status/timeout'],
['', 'internal.compute'],
].map(
([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}),
);
].map(([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}));
/**
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans

View File

@@ -10,7 +10,6 @@ import {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
cleanup,
fireEvent,
render,
@@ -316,99 +315,17 @@ describe('TracesExplorer -', () => {
});
});
it('test for explorer options', async () => {
const { getByText, getByTestId } = renderWithTracesExplorerRouter(
<TracesExplorer />,
['/traces-explorer/?panelType=list&selectedExplorerView=list'],
);
// assert explorer options - action btns
[
'Save this view',
'Create an Alert',
'Add to Dashboard',
'Select a view',
].forEach((val) => expect(getByText(val)).toBeInTheDocument());
const hideExplorerOption = getByTestId('hide-toolbar');
expect(hideExplorerOption).toBeInTheDocument();
fireEvent.click(hideExplorerOption);
// explorer options should hide and show btn should be present
await expect(
screen.findByTestId('show-explorer-option'),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('hide-toolbar')).toBeNull();
// show explorer options
const showExplorerOption = screen.getByTestId('show-explorer-option');
expect(showExplorerOption).toBeInTheDocument();
fireEvent.click(showExplorerOption);
// explorer options should show and hide btn should be present
await expect(
screen.findByTestId('hide-toolbar'),
).resolves.toBeInTheDocument();
});
it('select a view options - assert and save this view', async () => {
const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const viewSearchInput = await waitFor(() => {
const el = container.querySelector(
'.view-options .ant-select-selection-search-input',
) as HTMLElement;
expect(el).toBeInTheDocument();
return el;
});
fireEvent.mouseDown(viewSearchInput);
// The saved-views request has to land and re-render the dropdown before the
// option exists; findByRole's 1s default is not enough on a loaded runner.
await expect(
screen.findByRole('option', { name: 'R-test panel' }, { timeout: 5000 }),
).resolves.toBeInTheDocument();
// save this view
fireEvent.click(await screen.findByText('Save this view'));
const saveViewModalInput = await screen.findByPlaceholderText(
'e.g. External http method view',
);
expect(saveViewModalInput).toBeInTheDocument();
const saveViewModal = await waitFor(() => {
const el = document.querySelector('.ant-modal-content') as HTMLElement;
expect(el).toBeInTheDocument();
return el;
});
await act(async () =>
fireEvent.change(saveViewModalInput, { target: { value: 'test view' } }),
);
expect(saveViewModalInput).toHaveValue('test view');
await act(async () => {
fireEvent.click(within(saveViewModal).getByTestId('save-view-btn'));
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'View Saved Successfully',
});
});
}, 15000);
it('create a dashboard btn assert', async () => {
renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const createDashboardBtn = await screen.findByText('Add to Dashboard');
expect(createDashboardBtn).toBeInTheDocument();
const createDashboardBtn = await screen.findByTestId(
'explorer-add-to-dashboard',
);
await waitFor(() =>
expect(createDashboardBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
fireEvent.click(createDashboardBtn);
const createDashboardModal = (await screen.findByRole(
@@ -431,8 +348,10 @@ describe('TracesExplorer -', () => {
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const createAlertBtn = await screen.findByText('Create an Alert');
expect(createAlertBtn).toBeInTheDocument();
const createAlertBtn = await screen.findByTestId('explorer-create-alert');
await waitFor(() =>
expect(createAlertBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
fireEvent.click(createAlertBtn);
expect(historyPush).toHaveBeenCalledWith(

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { toast } from '@signozhq/ui/sonner';
import { screen, userEvent, waitFor } from 'storybook/test';
import { screen, userEvent } from 'storybook/test';
import type { GlobalMockArgs } from '../globals';
@@ -14,10 +14,10 @@ function EmptyPage(): JSX.Element {
const untilLoaded = { timeout: 10_000 };
/**
* The strip along the foot of the shell, behind the `SAVED_VIEW_ENABLED` flag:
* what the page puts on the left, the build version when it puts nothing, and
* the shell's own Ask Noz and Support on the right. Support follows the chat
* support the workspace has, the way the floating bubble it replaces did.
* The strip along the foot of the shell: what the page puts on the left, the
* build version when it puts nothing, and the shell's own Ask Noz and Support
* on the right. Support follows the chat support the workspace has, the way
* the floating bubble it replaces did.
*
* Route: every page inside the app shell; these stories use `/home`.
*/
@@ -78,38 +78,6 @@ export const Tooltips: Story = {
args: { noz: true, support: 'pylon', tooltipsOpen: true },
};
/**
* The flag off, on a cloud trial without a card: no strip, and chat support is
* the floating bubble in the corner.
*/
export const WithoutStrip: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
};
/** The Add Credit Card modal, opened from the floating bubble with the flag off. */
export const WithoutStripAddCardModal: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
play: async (): Promise<void> => {
// The bubble is an icon with no label or test id of its own.
const bubble = await waitFor(() => {
const found = document.querySelector<HTMLElement>(
'.chat-support-gateway-btn',
);
if (!found) {
throw new Error('the floating support bubble never rendered');
}
return found;
}, untilLoaded);
await userEvent.click(bubble);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
const raiseToast: NonNullable<Story['play']> = async () => {
toast.success('Service account deleted');
@@ -117,16 +85,10 @@ const raiseToast: NonNullable<Story['play']> = async () => {
};
/**
* A toast with the strip on. Sonner pins itself to the foot of the viewport, so
* A toast above the strip. Sonner pins itself to the foot of the viewport, so
* without an offset it lands on top of Ask Noz and Support.
*/
export const WithToast: Story = {
args: { noz: true, support: 'pylon' },
play: raiseToast,
};
/** The same toast with the flag off, back at sonner's own inset from the viewport. */
export const WithoutStripToast: Story = {
args: { bottomStrip: false, noz: true, support: 'pylon' },
play: raiseToast,
};

View File

@@ -26,14 +26,12 @@ function convertFormulasToV5(
limit: formulaData.limit ?? undefined,
legend: formulaData.legend,
bucketOptions: formulaData.bucketOptions,
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

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/telegram"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config/receiver"
@@ -32,6 +33,7 @@ var customNotifierIntegrations = []string{
jira.Integration,
jsmops.Integration,
incidentio.Integration,
telegram.Integration,
}
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
@@ -102,6 +104,11 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
return incidentio.New(c, tmpl, l, templater)
})
}
for i, c := range nc.TelegramConfigs {
add(telegram.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return telegram.New(c, tmpl, l, templater)
})
}
if errs.Len() > 0 {
return nil, &errs

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2022 Prometheus Team
// SPDX-License-Identifier: Apache-2.0
// Package telegram delivers alerts to a Telegram chat. It follows upstream's
// notifier for everything about the transport and adds the channel title and
// the per-rule title and body templates carried on the _title_template and
// _body_template annotations.
package telegram
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
)
const (
Integration = "telegram"
// Telegram supports 4096 chars max - from https://limits.tginfo.me/en.
maxMessageLenRunes = 4096
parseModeHTML = "HTML"
)
type Notifier struct {
conf *alertmanagertypes.TelegramReceiverConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
func New(conf *alertmanagertypes.TelegramReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
if conf.HTTPConfig == nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram http_config is nil")
}
if conf.APIUrl == nil {
withAPIURL := *conf
withAPIURL.APIUrl = config.DefaultGlobalConfig().TelegramAPIUrl
conf = &withAPIURL
}
// Rendering targets the HTML subset only. A receiver stored through the v1
// API can still carry another mode; failing here would fail the whole org's
// config, so the send is forced to HTML instead.
if conf.ParseMode != parseModeHTML {
l.WarnContext(context.Background(), "telegram parse_mode is not supported, sending as HTML", slog.String("parse_mode", conf.ParseMode))
htmlConf := *conf
htmlConf.ParseMode = parseModeHTML
conf = &htmlConf
}
httpclient, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: conf,
tmpl: t,
logger: l,
client: httpclient,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
logger := n.logger.With(slog.String("group_key", key.String()))
messageText, err := n.buildMessage(ctx, logger, as)
if err != nil {
return false, err
}
messageText, truncated := notify.TruncateInRunes(messageText, maxMessageLenRunes)
if truncated {
logger.WarnContext(ctx, "truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
botToken, err := n.getBotToken()
if err != nil {
return true, err
}
chatID, err := n.getChatID()
if err != nil {
return true, err
}
buf, err := n.encodeSendMessage(chatID, messageText)
if err != nil {
return false, err
}
resp, err := notify.PostJSON(ctx, n.client, n.conf.APIUrl.String()+"/bot"+botToken+"/sendMessage", buf) //nolint:bodyclose
if err != nil {
return true, notify.RedactURL(err)
}
defer notify.Drain(resp)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return shouldRetry, err
}
// encodeSendMessage builds the sendMessage payload with every value as a
// string, the encoding upstream's telebot client uses.
func (n *Notifier) encodeSendMessage(chatID int64, text string) (*bytes.Buffer, error) {
params := map[string]string{
"chat_id": strconv.FormatInt(chatID, 10),
"text": text,
"parse_mode": n.conf.ParseMode,
"disable_web_page_preview": "true",
}
if n.conf.DisableNotifications {
params["disable_notification"] = "true"
}
if n.conf.MessageThreadID != 0 {
params["message_thread_id"] = strconv.Itoa(n.conf.MessageThreadID)
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(params); err != nil {
return nil, err
}
return &buf, nil
}
// buildMessage sends the title in bold above the message. Each half comes from
// the rule's template when it has one and from the channel's otherwise; an
// empty title sends the message alone.
func (n *Notifier) buildMessage(ctx context.Context, logger *slog.Logger, as []*types.Alert) (string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
var result *alertmanagertypes.ExpandResult
if customTitle != "" || customBody != "" {
var err error
result, err = n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
}, as)
if err != nil {
return "", err
}
if len(result.MissingVars) > 0 {
logger.WarnContext(ctx, "unresolved variables in rule template", slog.Any("template_missing_vars", result.MissingVars))
}
}
var (
title, body string
err error
)
if customTitle != "" {
title, err = n.renderRuleTemplate(result.Title)
} else {
title, err = n.renderChannelTemplate(ctx, as, n.conf.Title)
}
if err != nil {
return "", err
}
if customBody != "" {
body, err = n.renderRuleTemplate(strings.Join(nonEmpty(result.Body), "\n\n"))
} else {
body, err = n.renderChannelTemplate(ctx, as, n.conf.Message)
}
if err != nil {
return "", err
}
if title == "" {
return body, nil
}
return strings.TrimSpace("<b>" + title + "</b>\n\n" + body), nil
}
// renderChannelTemplate reproduces upstream's rendering, including its choice
// of engine under parse_mode HTML: html/template escapes every interpolated
// value, so a channel template that does not pipe through html still sends.
func (n *Notifier) renderChannelTemplate(ctx context.Context, as []*types.Alert, text string) (string, error) {
if text == "" {
return "", nil
}
var tmplErr error
data := notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
rendered := notify.TmplHTML(n.tmpl, data, &tmplErr)(text)
if tmplErr != nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute telegram template: %s", tmplErr.Error())
}
return strings.TrimSpace(rendered), nil
}
// renderRuleTemplate converts the templater's output from markdown, which is
// the format rule templates are authored in for every channel, into Telegram's
// HTML subset. The renderer escapes as it goes, so a stray < or & in an alert
// value cannot fail the send with "can't parse entities".
func (n *Notifier) renderRuleTemplate(markdown string) (string, error) {
rendered, err := markdownrenderer.RenderTelegramHTML(markdown)
if err != nil {
return "", err
}
return strings.TrimSpace(rendered), nil
}
func (n *Notifier) getBotToken() (string, error) {
if n.conf.BotTokenFile == "" {
return string(n.conf.BotToken), nil
}
content, err := os.ReadFile(n.conf.BotTokenFile)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.BotTokenFile)
}
return strings.TrimSpace(string(content)), nil
}
func (n *Notifier) getChatID() (int64, error) {
if n.conf.ChatIDFile == "" {
return n.conf.ChatID, nil
}
content, err := os.ReadFile(n.conf.ChatIDFile)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.ChatIDFile)
}
chatID, err := strconv.ParseInt(strings.TrimSpace(string(content)), 10, 64)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not parse chat_id from %s", n.conf.ChatIDFile)
}
return chatID, nil
}
func nonEmpty(parts []string) []string {
kept := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
kept = append(kept, part)
}
}
return kept
}

View File

@@ -0,0 +1,390 @@
package telegram
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync"
"testing"
"time"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockBotAPI answers sendMessage the way api.telegram.org does. Every
// parameter is posted as a string.
type mockBotAPI struct {
srv *httptest.Server
mu sync.Mutex
sent []map[string]string
}
func newMockBotAPI(t *testing.T) *mockBotAPI {
t.Helper()
return newMockBotAPIWithStatus(t, http.StatusOK)
}
func newMockBotAPIWithStatus(t *testing.T, status int) *mockBotAPI {
t.Helper()
mock := &mockBotAPI{}
mock.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var params map[string]string
_ = json.NewDecoder(r.Body).Decode(&params)
mock.mu.Lock()
mock.sent = append(mock.sent, params)
mock.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status != http.StatusOK {
_, _ = w.Write([]byte(`{"ok":false,"error_code":` + strconv.Itoa(status) + `,"description":"failed"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":` + params["chat_id"] + `,"type":"private"}}}`))
}))
t.Cleanup(mock.srv.Close)
return mock
}
func (m *mockBotAPI) only(t *testing.T) map[string]string {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
require.Len(t, m.sent, 1)
return m.sent[0]
}
func newNotifier(t *testing.T, mock *mockBotAPI) *Notifier {
t.Helper()
return newNotifierWithTitle(t, mock, alertmanagertypes.DefaultTelegramTitleTemplate)
}
func newNotifierWithTitle(t *testing.T, mock *mockBotAPI, title string) *Notifier {
t.Helper()
tmpl := test.CreateTmpl(t)
apiURL, err := url.Parse(mock.srv.URL)
require.NoError(t, err)
logger := slog.New(slog.DiscardHandler)
notifier, err := New(&alertmanagertypes.TelegramReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
APIUrl: &config.URL{URL: apiURL},
BotToken: "123456:tok",
ChatID: -1001234567890,
ParseMode: "HTML",
Title: title,
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
require.NoError(t, err)
return notifier
}
func newAlert(host string, annotations model.LabelSet) *types.Alert {
merged := model.LabelSet{"summary": "p99 is 612ms"}
for name, value := range annotations {
merged[name] = value
}
return &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "checkout-latency-high", "severity": "warning", "host": model.LabelValue(host)},
Annotations: merged,
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
GeneratorURL: "http://am/alerts/overview?ruleId=r1",
},
}
}
func TestNotifyBuildsTheMessage(t *testing.T) {
testCases := []struct {
name string
untitledChannel bool
alerts []*types.Alert
wantExact string
wantContains []string
wantNotContains []string
}{
{
name: "NoRuleTemplates_UsesChannelTemplate",
alerts: []*types.Alert{newAlert("web-3", nil)},
wantContains: []string{
"<b>[FIRING:1] checkout-latency-high</b>\n\n<b>Alert:</b>",
"<b>Summary:</b> p99 is 612ms",
`<a href="http://am/alerts/overview?ruleId=r1">View in SigNoz</a>`,
},
},
{
// Receivers stored before the channel title existed have none.
name: "UntitledChannel_SendsTheMessageAlone",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", nil)},
wantContains: []string{"<b>Summary:</b> p99 is 612ms"},
wantNotContains: []string{"[FIRING:1]"},
},
{
name: "TitleAndBody_ReplaceTheChannelTemplate",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
})},
wantExact: "<b>Incident on web-3</b>\n\nfiring for checkout-latency-high",
wantNotContains: []string{"View in SigNoz"},
},
{
// $-refs are template variables, so the braced form is the real
// syntax and the only one that reaches conditionals. A bare ref in
// text is rewritten into an action before expansion.
name: "BracedAndBareRefs_ExpandAlike",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "{{ $rule.name }}|$rule.name|{{ if $alert.is_firing }}up{{ end }}",
})},
wantExact: "checkout-latency-high|checkout-latency-high|up",
},
{
name: "BodyOnly_KeepsTheChannelTitle",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
})},
wantExact: "<b>[FIRING:1] checkout-latency-high</b>\n\nfiring for checkout-latency-high",
},
{
name: "BodyOnly_UntitledChannel_OmitsTheTitleLine",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
})},
wantExact: "firing for checkout-latency-high",
},
{
name: "TitleOnly_ReplacesTheChannelTitle",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
})},
wantContains: []string{"<b>Incident on web-3</b>\n\n<b>Alert:</b>", "<b>Summary:</b> p99 is 612ms"},
wantNotContains: []string{"[FIRING:1]"},
},
{
// Rule templates are authored in markdown for every channel, so
// Telegram converts rather than escaping the markers away.
name: "RuleTemplateMarkdown_RenderedToTelegramHTML",
untitledChannel: true,
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
ruletypes.AnnotationBodyTemplate: "**{{ $rule.name }}** on `{{ $labels.host }}` — [open](https://signoz.io/d?a=1&b=2)",
})},
wantExact: `<b>checkout-latency-high</b> on <code>web-3</code> — <a href="https://signoz.io/d?a=1&amp;b=2">open</a>`,
},
{
// The templater renders plain text, so anything interpolated from
// an alert has to be escaped before a parse_mode HTML send.
name: "RuleTemplateOutput_EscapedForHTML",
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
"summary": "p99 < 2s & rising",
ruletypes.AnnotationTitleTemplate: "$annotations.summary",
ruletypes.AnnotationBodyTemplate: "$annotations.summary",
})},
wantExact: "<b>p99 &lt; 2s &amp; rising</b>\n\np99 &lt; 2s &amp; rising",
},
{
name: "GroupedAlerts_BodyRenderedPerAlert",
untitledChannel: true,
alerts: []*types.Alert{
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
},
wantExact: "host web-3\n\nhost web-4",
},
{
// Picking one rule's template for a mixed group would be arbitrary,
// so the channel template is used instead.
name: "GroupedAlerts_DisagreeingTemplates_FallBackToChannel",
alerts: []*types.Alert{
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "something else"}),
},
wantContains: []string{"<b>Summary:</b> p99 is 612ms"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mock := newMockBotAPI(t)
title := alertmanagertypes.DefaultTelegramTitleTemplate
if testCase.untitledChannel {
title = ""
}
retry, err := newNotifierWithTitle(t, mock, title).Notify(notify.WithGroupKey(context.Background(), "1"), testCase.alerts...)
require.NoError(t, err)
assert.False(t, retry)
text := mock.only(t)["text"]
if testCase.wantExact != "" {
assert.Equal(t, testCase.wantExact, text)
}
for _, want := range testCase.wantContains {
assert.Contains(t, text, want)
}
for _, notWant := range testCase.wantNotContains {
assert.NotContains(t, text, notWant)
}
})
}
}
// A v1-written receiver can carry a mode the v2 spec no longer offers. Nothing
// renders to those, so the send goes out as HTML without touching the receiver.
func TestNewForcesParseModeHTML(t *testing.T) {
testCases := []struct {
name string
parseMode string
}{
{"HTML", "HTML"},
{"MarkdownV2", "MarkdownV2"},
{"Markdown", "Markdown"},
{"Empty", ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mock := newMockBotAPI(t)
apiURL, err := url.Parse(mock.srv.URL)
require.NoError(t, err)
tmpl := test.CreateTmpl(t)
logger := slog.New(slog.DiscardHandler)
conf := &alertmanagertypes.TelegramReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
APIUrl: &config.URL{URL: apiURL},
BotToken: "123456:tok",
ChatID: -1001234567890,
ParseMode: testCase.parseMode,
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
}
notifier, err := New(conf, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
require.NoError(t, err)
_, err = notifier.Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
require.NoError(t, err)
assert.Equal(t, "HTML", mock.only(t)["parse_mode"])
assert.Equal(t, testCase.parseMode, conf.ParseMode)
})
}
}
func TestNewDefaultsAPIURL(t *testing.T) {
testCases := []struct {
name string
apiURL string
expected string
}{
{"Unset_DefaultsToTelegram", "", "https://api.telegram.org"},
{"Set_KeepsChannelURL", "http://127.0.0.1:9099", "http://127.0.0.1:9099"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
conf := &alertmanagertypes.TelegramReceiverConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
BotToken: "123456:tok",
ChatID: -1001234567890,
ParseMode: "HTML",
}
if testCase.apiURL != "" {
apiURL, err := url.Parse(testCase.apiURL)
require.NoError(t, err)
conf.APIUrl = &config.URL{URL: apiURL}
}
tmpl := test.CreateTmpl(t)
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
require.NoError(t, err)
require.NotNil(t, notifier.conf.APIUrl)
assert.Equal(t, testCase.expected, notifier.conf.APIUrl.String())
if testCase.apiURL == "" {
assert.Nil(t, conf.APIUrl, "the receiver is not modified")
}
})
}
}
func TestNotifySendsTheConfiguredSendOptions(t *testing.T) {
mock := newMockBotAPI(t)
_, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
require.NoError(t, err)
sent := mock.only(t)
assert.Equal(t, "-1001234567890", sent["chat_id"])
assert.Equal(t, "HTML", sent["parse_mode"])
}
func TestNotifyRetriesOnlyRateLimitAndServerErrors(t *testing.T) {
testCases := []struct {
name string
status int
wantErr bool
wantRetry bool
}{
{"OK_NoRetry", http.StatusOK, false, false},
{"TooManyRequests_Retries", http.StatusTooManyRequests, true, true},
{"InternalServerError_Retries", http.StatusInternalServerError, true, true},
{"BadGateway_Retries", http.StatusBadGateway, true, true},
{"BadRequest_DoesNotRetry", http.StatusBadRequest, true, false},
{"Unauthorized_DoesNotRetry", http.StatusUnauthorized, true, false},
{"Forbidden_DoesNotRetry", http.StatusForbidden, true, false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
mock := newMockBotAPIWithStatus(t, testCase.status)
retry, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
if testCase.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, testCase.wantRetry, retry)
})
}
}
func TestNotifyPostsToTheBotSendMessageEndpoint(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_, _ = w.Write([]byte(`{"ok":true}`))
}))
t.Cleanup(srv.Close)
_, err := newNotifier(t, &mockBotAPI{srv: srv}).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
require.NoError(t, err)
assert.Equal(t, "/bot123456:tok/sendMessage", gotPath)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -253,6 +254,24 @@ func (r *BaseRule) GeneratorURL() string {
return r.ExternalURL("alerts/overview", params)
}
// ExpandAnnotations runs expand over the rule's annotations, leaving private
// ones as they are. Those carry templates the notifier expands against its own
// data model, and the ruler's expander rewrites every $-ref into a label
// lookup, which blanks the ones that are not labels.
func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Labels {
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
if alertmanagertypes.IsPrivateAnnotation(name) {
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
continue
}
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
return annotations
}
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
u := r.externalURL.JoinPath(path)
if len(params) > 0 {

View File

@@ -833,3 +833,55 @@ func labelsKey(lbls []*qbtypes.Label) string {
}
return temp.Labels().String()
}
func TestExpandAnnotations(t *testing.T) {
testCases := []struct {
name string
annotations map[string]string
want map[string]string
}{
{
name: "PublicAnnotation_Expanded",
annotations: map[string]string{"summary": "observed {{$value}}"},
want: map[string]string{"summary": "observed 0.35"},
},
{
// The ruler's expander rewrites every $-ref into a label lookup, so
// expanding these would blank the paths the notifier resolves.
name: "PrivateAnnotation_LeftAlone",
annotations: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
want: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
},
{
name: "MixedAnnotations_OnlyPublicExpanded",
annotations: map[string]string{
"summary": "observed {{$value}}",
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
},
want: map[string]string{
"summary": "observed 0.35",
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &BaseRule{annotations: ruletypes.FromMap(testCase.annotations)}
expand := func(text string) string {
tmpl := ruletypes.NewTemplateExpander(
context.Background(),
"{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"+text,
"__alert_test",
ruletypes.AlertTemplateData(map[string]string{}, "0.35", "0.32"),
nil,
)
out, err := tmpl.Expand()
require.NoError(t, err)
return out
}
assert.Equal(t, testCase.want, rule.ExpandAnnotations(expand).Map())
})
}
}

View File

@@ -211,10 +211,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
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)})
}
annotations := r.ExpandAnnotations(expand)
if result.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -295,10 +295,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
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)})
}
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -130,7 +130,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
}
return defaultDefName
}))
}), jsonschema.InterceptProp(keepDescriptionOnReferencedProperty))
reflector.Spec.WithInfo(*(&openapi3.Info{}).
WithTitle("SigNoz").
@@ -169,6 +169,27 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
}, nil
}
// keepDescriptionOnReferencedProperty wraps a referenced property schema in
// allOf when the field carries a description. OpenAPI 3.0 allows no siblings
// next to $ref, so without this the description tag on a field whose type is a
// named schema (an enum or a struct) is silently dropped from the spec.
func keepDescriptionOnReferencedProperty(params jsonschema.InterceptPropParams) error {
if !params.Processed || params.PropertySchema == nil {
return nil
}
schema := params.PropertySchema
if schema.Ref == nil || schema.Description == nil || *schema.Description == "" {
return nil
}
ref := *schema.Ref
schema.Ref = nil
schema.AllOf = []jsonschema.SchemaOrBool{(&jsonschema.Schema{}).WithRef(ref).ToSchemaOrBool()}
return nil
}
func (openapi *OpenAPI) CreateAndWrite(path string) error {
if err := openapi.apiserver.Router().Walk(openapi.collector.Walker); err != nil {
return err

View File

@@ -0,0 +1,46 @@
package signoz
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/swaggest/jsonschema-go"
)
type describedKind string
func (describedKind) Enum() []any { return []any{"a", "b"} }
type describedNested struct {
Value string `json:"value"`
}
type describedParent struct {
Kind describedKind `json:"kind" description:"The kind of thing."`
Nested describedNested `json:"nested" description:"The nested thing."`
Undescribed describedKind `json:"undescribed"`
Plain string `json:"plain" description:"A plain string."`
}
func TestKeepDescriptionOnReferencedProperty(t *testing.T) {
reflector := jsonschema.Reflector{}
reflector.DefaultOptions = append(reflector.DefaultOptions, jsonschema.InterceptProp(keepDescriptionOnReferencedProperty))
schema, err := reflector.Reflect(describedParent{})
require.NoError(t, err)
raw, err := json.Marshal(schema)
require.NoError(t, err)
var got struct {
Properties map[string]json.RawMessage `json:"properties"`
}
require.NoError(t, json.Unmarshal(raw, &got))
assert.JSONEq(t, `{"description":"The kind of thing.","allOf":[{"$ref":"#/definitions/SignozDescribedKind"}]}`, string(got.Properties["kind"]))
assert.JSONEq(t, `{"description":"The nested thing.","allOf":[{"$ref":"#/definitions/SignozDescribedNested"}]}`, string(got.Properties["nested"]))
assert.JSONEq(t, `{"$ref":"#/definitions/SignozDescribedKind"}`, string(got.Properties["undescribed"]))
assert.JSONEq(t, `{"description":"A plain string.","type":"string"}`, string(got.Properties["plain"]))
}

View File

@@ -262,6 +262,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
)
}

View File

@@ -0,0 +1,188 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type backfillTelegramChannelSpec struct {
logger *slog.Logger
}
func NewBackfillTelegramChannelSpecFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("backfill_telegram_channel_spec"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &backfillTelegramChannelSpec{logger: ps.Logger}, nil
})
}
func (migration *backfillTelegramChannelSpec) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up fills the spec of telegram channels add_channel_spec left NULL because v2
// did not model telegram then. parse_mode is not carried: the spec has no field
// for it and every send goes out as HTML. Rows carrying any other notifier are
// left to the repair endpoint.
func (migration *backfillTelegramChannelSpec) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*channelSpecBackfillRow, 0)
if err := tx.NewSelect().Model(&rows).Where("spec IS NULL").OrderExpr("org_id, id").Scan(ctx); err != nil {
return err
}
for _, row := range rows {
kind, spec, err := telegramChannelSpecFromReceiverJSON(row.Data)
if kind != "telegram" {
continue
}
if err != nil {
migration.logger.WarnContext(ctx, "leaving telegram notification channel without a v2 spec", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
encoded, err := marshalUnescaped(spec)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*channelSpecBackfillRow)(nil)).
Set("spec = ?", string(encoded)).
Set("type = ?", kind).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
migration.logger.InfoContext(ctx, "filled v2 spec on telegram notification channel", slog.String("org_id", row.OrgID), slog.String("channel_id", row.ID))
}
return tx.Commit()
}
func (migration *backfillTelegramChannelSpec) Down(context.Context, *bun.DB) error {
return nil
}
// telegramChannelSpecFromReceiverJSON reads the kind from the receiver rather
// than the stored type, which v1 could leave empty.
func telegramChannelSpecFromReceiverJSON(data string) (string, map[string]any, error) {
receiver := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(data), &receiver); err != nil {
return "", nil, nil
}
total := 0
var telegrams []notifierJSON
for key, raw := range receiver {
if !strings.HasSuffix(key, "_configs") {
continue
}
var list []notifierJSON
if err := json.Unmarshal(raw, &list); err != nil {
if key != "telegram_configs" {
return "", nil, nil
}
return "telegram", nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
}
total += len(list)
if key == "telegram_configs" {
telegrams = list
}
}
if len(telegrams) == 0 {
return "", nil, nil
}
if total > 1 {
return "telegram", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "carries %d notifier configurations; only one per channel is supported", total)
}
spec, err := convertTelegramNotifierJSON(telegrams[0])
return "telegram", spec, err
}
func convertTelegramNotifierJSON(notifier notifierJSON) (map[string]any, error) {
if err := rejectAnyHTTPAuthJSON(notifier); err != nil {
return nil, err
}
for _, key := range []string{"token_file", "chat_file"} {
value, err := notifier.stringValue(key)
if err != nil {
return nil, err
}
if value != "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram: %s is not supported", key)
}
}
spec := map[string]any{}
if err := notifier.copyStrings(spec, map[string]string{"token": "botToken"}); err != nil {
return nil, err
}
if err := notifier.copyNonEmptyStrings(spec, map[string]string{"api_url": "apiUrl", "message": "message"}); err != nil {
return nil, err
}
if spec["botToken"] == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram: token is required")
}
chatID, err := notifier.int64Value("chat")
if err != nil {
return nil, err
}
if chatID == 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram: chat is required")
}
spec["chatId"] = chatID
threadID, err := notifier.int64Value("message_thread_id")
if err != nil {
return nil, err
}
if threadID != 0 {
spec["messageThreadId"] = threadID
}
disableNotifications, err := notifier.boolValue("disable_notifications")
if err != nil {
return nil, err
}
if disableNotifications {
spec["disableNotifications"] = true
}
sendResolved, err := notifier.boolValue("send_resolved")
if err != nil {
return nil, err
}
spec["sendResolved"] = sendResolved
return spec, nil
}
func (n notifierJSON) int64Value(key string) (int64, error) {
if !n.has(key) {
return 0, nil
}
var value int64
if err := json.Unmarshal(n[key], &value); err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", key)
}
return value, nil
}

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