Compare commits

...

14 Commits

Author SHA1 Message Date
Naman Verma
407be7d267 fix: make related logs/traces link carry the relevant query from a rule (#13112)
Some checks are pending
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
build-staging / staging (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

If an alert has multiple queries inside it, a threshold can still only
carry one of them. If a threshold rule is breached, the related
logs/traces link carried in the notification and in the alert history
page doesn't always carry the actual query that was involved.

This PR fixes the above issue. In the case of a formula being used in
the threshold, the related logs/traces link carries all the queries that
the formula is based on joined by an `OR`.

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

Closes https://github.com/SigNoz/signoz/issues/12562

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/acb1aea8-fcc5-4778-ba7a-af4ca814d2bd



https://github.com/user-attachments/assets/7a650613-8deb-471d-8549-5b3f08a600ed
2026-10-22 08:10:24 +00:00
ashu8912
089de88bf0 feat(alerts): adopt server-side clone API in frontend (#13181)
#### Description

- Switches alert rule cloning from client-side re-posting to the new
server-side `POST /api/v2/rules/{id}/clone` endpoint.
- Updates `ActionsMenu` in `ListAlertRules` to call `cloneRuleByID({ id:
rule.id })` instead of re-posting the in-memory rule object with a
forced typecast.
- Updates `useAlertRuleDuplicate` in `AlertDetails` to navigate directly
using the newly created rule ID from the clone response, eliminating the
fragile list refetch race condition (`rules[rules.length - 1]`).
- Updates `ListAlertRules.actions.test.tsx` to mock `POST
/api/v2/rules/:id/clone` and verify navigation and event logging.

#### Issues closed by this PR

- Follow-up to the backend changes on `alerts/clone_alert_rule_api`.
- Closes https://github.com/SigNoz/pulse-pod/issues/597

#### Additional Information

- **Base branch:** Targeted against `alerts/clone_alert_rule_api`
(backend branch).
- Tested locally: all 11 test suites in `ListAlertRules` pass (64 tests
total) and `npx tsc --noEmit` compiles with 0 errors.
2026-10-22 07:54:53 +00:00
Naman Verma
e5a39e3489 fix: put non-positive formula heatmap values in the zero bucket (#13217)
<!--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

Every value at or below zero shares one bucket with upper bound 0 on
both axes. A formula on a linear axis put them in the first positive
bucket instead.

Integration tests now cover negative and zero values on both axes, for
builder queries and formulas.

<!--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/602
2026-10-22 07:25:18 +00:00
praneeth-signoz
50b48649dd fix(alertmanager): treat missing dotted labels as nil in downtime scope (#13118)
<!--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 downtime scope reading a dotted label the alert lacks
(`cluster.name`) failed with `cannot fetch name from <nil>`, aborting
the whole expression, so a matching `OR` branch never muted the alert.
- Member access in scopes is now nil-safe (`a.b` → `a?.b`), so a missing
dotted label compares like a missing plain label.

<!--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/526

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
2026-10-22 06:42:33 +00:00
Ashwin Bhatkal
9fc6fbb9fa test(e2e): cover the notification channel list, create and delete flows (#13216)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

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

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

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

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

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

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

#### Issues closed by this PR

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


#### Screenshots / Screen Recordings

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

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

One caveat on that page: it was captured before the global button font
rule was reverted, so it counts more changed screens than the branch now
has. The groups and what each one shows are still accurate.
2026-10-21 19:57:18 +00:00
Ashwin Bhatkal
fcb685bf37 feat(notification-channels): add the telegram channel form (#13119)
#### Description

Gives Telegram a form, now that the v2 API models the kind. Last in the
stack, so it is written against the v2 schemas rather than the v1 form
it would have had to use underneath.

- `Settings/Telegram.tsx`: bot token, the numeric chat id, an optional
forum topic id, the message template, an optional self-hosted Bot API
URL, and a send-silently toggle.
- Telegram joins the channel type select and `renderSettings()`.
- Stories for the new and edit screens, a prefill case covering every
field, and a round-trip case so a saved channel comes back unchanged.

#### Issues closed by this PR

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

#### Screenshots / Screen Recordings

[Screen diff against `main`, dark and
light](https://claude.ai/artifact/SeVW4fTm88oSCJpiCq2UM8)

Four new screens: the Telegram form, editing one, saving with the
required fields empty, and one with update denied. The other **115
screens are pixel for pixel the same** as on main, in both themes. The
single remaining row is a focus outline left by a story's own click, 95
pixels, checked by cropping both shots.

#### Additional Information

`chatId` is an int64 and `messageThreadId` an int, the first numeric
fields the channel form has had. An empty or half-typed box sends
nothing rather than `NaN`, which would serialise as `null` and be
rejected. The prefill test's field tuples widen to `string | number`
because a number input reports a number.

The message template is left out of the defaults on purpose: the API
fills `DefaultTelegramMessageTemplate` when the key is absent, and
sending an empty string would overwrite it.

The kind itself was taught to the list, the filter and the JSON view
(including masking the bot token) in the base PR, which is what the
exhaustive per-kind maps require once the schema carries `telegram`.
2026-10-21 17:38:49 +00:00
Vikrant Gupta
e69d3fcc8c fix(metricreduction): type the no-kept-labels preview count so the driver can scan it (#13221)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Previewing a rule that drops every non-protected label of a metric
returned 500 "failed to estimate reduction impact". With no kept labels
the cardinality query selects a bare `1`, which ClickHouse types as
`UInt8`, and the driver refuses to scan that into the `uint64` the code
reads it into.
- The literal is now `toUInt64(1)`, matching the scan target, so such
previews report one retained series as intended.
2026-10-21 12:33:51 +00:00
Naman Verma
354214ca87 chore: drop sloglint key-naming-case (#13220)
<!--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

Dotted keys like rule.id are already the convention across the codebase.
2026-10-21 12:04:26 +00:00
Vikrant Gupta
e3488c1592 feat(authz): enable FGA for metrics explorer routes (#13150)
#### Description

- Metrics explorer routes were still on the coarse `ViewAccess` gate, so
a custom role scoped to a workspace key on metrics could list, inspect
and read stats for every metric through them while being denied on
`query_range`.
- Stats, treemap, inspect, highlights and onboarding derive the resource
from the caller's `filter.expression` or resolve to the metrics
wildcard, exactly as `query_range` does. Key-scoped custom roles get 403
unless the filter pins a granted key.
- A new `metrics-field` metaresource covers the metrics list, the
metadata and attributes reads, and the metadata update, with wildcard
grants: admin and editor may update, every managed role may list and
read. Existing organizations get the tuples through a migration.
- The alerts and dashboards lookups read the rule and dashboard stores,
so they require `rule:list` and `dashboard:list`.
- The new `metricsexplorer` integration suite is added to the CI matrix.

#### Issues closed by this PR

Part of SigNoz/keystone-pod#141
2026-10-21 11:40:56 +00:00
Swapnil Nakade
574bfa62b8 feat: enabled logs and added collection strategy for gcp integration (#13184)
<!--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
- with release of 0.163.0 of collector contrib, `googlecloudpubsubpush`
receiver is published
- enabling logs services in gcp integration for manual flow

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/164

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-21 11:40:25 +00:00
Vikrant Gupta
ba6c4f83a6 docs(authz): explain telemetry resources versus metaresources (#13186)
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

- The authz guide listed the `telemetryresource` and `metaresource`
types but never said when a route belongs to which, so new routes have
been landing on role gates or the wrong type.
- Adds a section contrasting the two: telemetry resources are the data,
read-only, with the selector derived from the query; metaresources are
objects about the data with CRUD verbs and ID or wildcard selectors. The
metrics explorer is the worked example, with `metrics:read` for stats
against `metrics-field:list` and `metrics-field:update` for the
catalogue and metadata write.
- Adds the rule of thumb to the summary list.

#### Additional Information

Contributes to: https://github.com/SigNoz/keystone-pod/issues/141
2026-10-21 08:10:56 +00:00
Ashwin Bhatkal
ada6a20ea2 docs(skills): add a screenshot diff skill and fix misleading test advice (#13206)
#### Description

Adds a skill for taking before/after screenshots with sbshot, and fixes
advice in the page-story skill that told people to write tests which
always pass.

- **New `sbshot-visual-diff` skill.** sbshot runs through `npx`, so
nothing it knows comes into this repo on its own and the steps were
being worked out again each time. The skill covers the parts that are
easy to get wrong: start a fresh Storybook for each branch, shoot both
themes, compare against the merge-base, measure the noise instead of
arguing about it, and export the result so a reviewer gets a link rather
than a localhost address.
- **It also says to add the stories first.** A screenshot diff can only
show what a story renders, so a new screen with no story shows up as
"nothing changed" rather than "nobody looked".
- **Fixed the page-story skill.** It told you to read `disabled` off a
button the permission turns off. Our components use `aria-disabled`,
which `toBeEnabled` and `toBeDisabled` ignore, so those tests pass on
buttons nobody can click. It now says which attribute to check, what to
do for controls that carry no attribute at all, and how to confirm a new
assertion can actually fail.

#### Additional Information

**Why a separate skill, when `references/verify.md` already mentions
sbshot.**

A rule only gets followed if it is where the work happens. `verify.md`
is about checking one story you have just written. Comparing two
branches is a different job, usually started by someone who is not
writing a story at all, and it brings its own questions: which baseline
to use, how to tell noise from a real change, how to hand a reviewer
something they can open. Someone doing that job has no reason to open a
file about verifying a single story, so a rule parked there does not
reach them, even when it applies to both jobs.

Skills load by matching what is being asked. Folded in as another
reference under `signoz-page-story`, this would only load while someone
was already writing stories, which is the wrong moment. So it is its own
skill, and `verify.md` now ends with a pointer to it, so each file has
one job and they do not drift apart.

Documentation only, no runtime code. Based on `main`.
2026-10-21 02:04:16 +00:00
Vinicius Lourenço
34e65a2533 chore(e2e): address tinypool audit (#13205)
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

- Follow-up to #13203. `tests/e2e` has its own lockfile, which still
pulled `tinypool@2.1.0` through `oxfmt@0.41.0` (GHSA-5gmw-xhrv-c9v3,
GHSA-85c8-ppgw-ccpr).
- Pinned `oxfmt`, `oxlint` and `oxlint-tsgolint` to the frontend
versions. `tinypool` now resolves to 2.2.0.
- `pnpm fmt:check` and `pnpm lint` pass with no file changes.
2026-10-20 21:22:16 +00:00
120 changed files with 4883 additions and 1228 deletions

View File

@@ -0,0 +1,180 @@
---
name: sbshot-visual-diff
description: Shoot Storybook stories with sbshot at two git refs and produce a diff a reviewer can read. Use when asked for a visual diff, before/after screenshots, a screenshot comparison for a PR, or to prove a change did not alter screens it should not have.
---
# sbshot visual diffs
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs.
Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare:
sbshot already handles story selection, themes, settling, retries, diffing,
tagging, a viewer and a static export, and its runs are the format the rest of
the team can open.
sbshot is not installed by any package. It is run through `npx`, so nothing it
knows travels into this repo on its own. This file is where that knowledge
lives.
## The shape of a run
```
capture <storybook> --name-run <ref> # once per git ref
diff <baseline> <after> # pairs them by relative path
tag <diff> <tag> <story-id>... # what the reviewer should look at
export <diff> --out <dir> # a static site, hostable
```
`<storybook>` is a running dev server (`http://localhost:6006`), a build
directory holding `index.json`, or a project directory with `.storybook/`. The
frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so
point `capture` at a dev server.
## Cover the change with stories first
**A diff can only show what a story renders.** A screen, state or variant with no
story is invisible to sbshot: it produces no pair, so it reads as "nothing
changed" rather than "never looked at". This is the failure mode that makes a
clean-looking diff worthless.
Before capturing, list the screens and states the change touches and check each
has a story. For anything missing, write it first — the `signoz-page-story`
skill covers how. New states reached only by an interaction need a `play`
function, or the capture shoots the page at rest and the state never appears.
Then confirm it worked: the two runs' story counts should differ by exactly the
stories the change adds. A new screen that produced no new story id is a screen
nobody is reviewing. Say the count in the summary, so a reader can tell coverage
from silence.
## Rules that are not optional
**One freshly started Storybook per ref.** Never start one server and switch git
branches under it. Vite's HMR reloads the page, but some modules do not
re-initialise, so the shot differs silently from a clean boot and the difference
looks like a real change. Settle time does not fix this and is not the variable.
Use one worktree and one port per ref, and stop each server before the next.
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the
story's own default and everything lands in a single `default/` folder, so every
regression in the other theme is invisible. Themes double the shot count, so
scope with `--stories` or `--title`, never by dropping a theme.
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main
<branch>`. Against a moved main, every commit main gained since the branch's base
shows up reversed, as though the branch reverted it.
**The percentage sorts the list, it does not tell you what matters.** A dialog
that dims the page scores high for a one-line change, and a whole new form
section can score low. Open the shots and say what changed on each screen. The
biggest number is usually the least interesting one.
**If you cannot explain a pair, assume the capture is at fault, not the code.**
Open it, crop both sides and compare them before calling it noise. A pixel count
and a good guess are not evidence.
## Noise, and the floor that proves it
The same story does not always render identically twice. Pairs often move for
reasons that have nothing to do with the change:
- a focus ring left on whatever a `play` function clicked last
- a scrollbar appearing, or its thumb resting a few pixels off
- a toast, tooltip or dialog caught mid-fade
- a text caret blinking, or a selection highlight
- an animation or spinner at a different frame
- relative timestamps ("2 days ago") crossing a boundary
- hover or active styling from where the pointer happened to rest
- a late-loading font or image shifting a line of text
Do not argue about these one at a time. **Measure them**: shoot the same commit
twice and diff those two runs against each other. Anything that moves there moved
for no reason, so it is noise.
```
capture <storybook> --name-run noise-a
capture <storybook> --name-run noise-b # same tree, its own fresh server
diff noise-a noise-b --name-diff noise
diff <baseline> <after> --noise noise # marks the movers flaky
```
**The noise run has to cover the same stories as the real diff.** Do not scope it
down to the few pairs that moved, however tempting that is. `--noise` marks a
pair flaky when the noise diff does not show it as unchanged, so every story the
noise run leaves out is marked flaky too, including the new screens the reviewer
is there to look at. Shooting two narrow runs and diffing those against each
other has the same problem. Either shoot the full set twice, or skip `--noise`
and explain the few movers by hand.
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the
noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run
hides a real 8% change in the real one. Compare the two numbers before trusting
the mark. When most of the flaky marks are hiding real changes, run the diff
without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing
real is filtered out of the reviewer's view.
That is the trade: a second full capture costs about as long as the first, so on
a small diff it is often quicker to crop the two or three movers and say what
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
into CI, where something has to decide without a person looking.
If a pair is flaky every run and the state it captures matters, fix the story
rather than living with it: blur what the `play` clicked, freeze the clock, or
assert the state instead of shooting it.
## Making it readable for a reviewer
A reviewer cannot open `localhost`, so `sbshot ui` is for you, not for them.
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by
tag, so the reviewer lands on the real changes instead of scrolling past
noise that scored higher.
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs
that are not identical, converted to WebP, so a large diff stays small.
3. Host it. A Claude artifact works: publish the export's files and give the
reviewer the link. `index.html` is reserved for the artifact's own page, so
publish sbshot's entry as `viewer.html` and link to
`viewer.html#/job/diff/<diff>`.
4. Put the link in the PR body under **Screenshots / Screen Recordings**.
Say the numbers plainly: how many screens changed, how many are new, how many are
pixel-identical, how many are flaky. The count of unchanged screens is often the
most useful claim in the diff, because it is the one a reviewer cannot check by
eye.
## Operational traps
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how
many to shoot at once by sampling free memory, and off Linux it reads
`os.freemem()`, which counts only truly free pages. macOS parks almost
everything in "inactive" instead, which is reclaimable on demand, so the
sampler can read ~100 MB on a 25 GB machine and shrink to one forever.
`--jobs` does not help: it is only an upper cap. Check `parallelism` in
`<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes
inactive, that is this. A 124-shot run takes about 90 minutes pinned and about
6 minutes at four at a time, so it is worth spotting before a long capture.
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if
something already holds that port. Free it first.
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails
with ``unable to read font `' ``. Map Helvetica to a real file in
`~/.config/ImageMagick/type.xml`.
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it
symlinks. Keep the source directory alive until you have exported.
- The export directory is build output. Delete it when done, or gitignore it.
- Running the whole Storybook test suite locally fails broadly with
`page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
## Done means
- [ ] every screen and state the change touches has a story, written before capturing
- [ ] one capture per ref, each from its own freshly started Storybook
- [ ] `--theme dark,light` on every capture
- [ ] baseline is the merge-base
- [ ] noise either measured with a full second capture, or each mover explained by hand
- [ ] every non-flaky pair opened and explained, not inferred from its size
- [ ] the pairs that matter tagged, with a note
- [ ] exported and hosted, and the link in the PR body

View File

@@ -151,9 +151,42 @@ that only exists on a trial needs the plan too:
`args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission
whose denial changes nothing on screen gets no story: say so in the PR.
Verify these by their disabled states, not their text. The page reads the same
either way, so a story that is wrong looks right: read `disabled` off the buttons
the permission gates, and check the denial callout is there or gone.
Check these by what is turned off, not by what the page says. The page reads the
same either way, so a story that is wrong still looks right. Check that the
control the permission guards is off, and that the denial message is there or
gone.
**Do not use `toBeEnabled` or `toBeDisabled`.** They only look at the `disabled`
attribute. `@signozhq/ui` turns a button off with `aria-disabled` instead, so
these matchers pass on a button nobody can click, and the test goes green while
clicking straight through a denial. Check the attribute the component really
sets:
```ts
expect(save).toHaveAttribute('aria-disabled', 'true');
```
**Some controls are off with no attribute at all.** The browser turns off
everything inside a disabled `<fieldset>` without marking it, and a switch is a
`<button>` underneath. For those, an attribute check proves nothing, and so does
looking for a parent `fieldset[disabled]`. Click it and check nothing happened:
```ts
const before = toggle.getAttribute('aria-checked');
await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
```
**Check that a new assertion can fail.** Flip it once and run it. If that story
goes red and nothing else does, the assertion is real; put it back. A test that
cannot fail is worse than no test, because it looks like coverage.
**Delete a story once its difference disappears.** Say a story checks that a
button reads "View" rather than "Edit" when a permission is missing. If the label
later becomes the same either way, that story passes no matter which permissions
are held, including all of them, and it is testing nothing. When a behaviour
stops depending on a permission, delete the story or rewrite it to lock in the
new behaviour, and say which one you did in the PR.
## Rules

View File

@@ -63,6 +63,10 @@ not catch an unstyled page. Take them with
[sbshot](https://github.com/SigNoz/sbshot), e.g.
`sbshot capture http://localhost:6006 --title <story title> --theme dark,light`.
That is one run, for checking the story you just wrote. Comparing two branches is
a different job: see the `sbshot-visual-diff` skill, which covers the baseline to
pick, measuring the noise and exporting something a reviewer can open.
## Gates
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an

View File

@@ -75,6 +75,7 @@ jobs:
- ttl
- clickhousecluster
- metricreduction
- metricsexplorer
sqlstore-provider:
- postgres
- sqlite

View File

@@ -41,7 +41,6 @@ linters:
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:

View File

@@ -3581,6 +3581,7 @@ components:
- meter-metrics
- logs-field
- traces-field
- metrics-field
type: string
CoretypesObject:
properties:
@@ -20637,9 +20638,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:list
- tokenizer:
- VIEWER
- metrics-field:list
summary: List metric names
tags:
- metrics
@@ -20706,9 +20707,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Get metric alerts
tags:
- metrics
@@ -20790,9 +20791,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric attributes
tags:
- metrics
@@ -20860,9 +20861,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metric highlights
tags:
- metrics
@@ -20919,9 +20920,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Inspect raw metric data points
tags:
- metrics
@@ -20989,9 +20990,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric metadata
tags:
- metrics
@@ -21035,9 +21036,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- metrics-field:update
- tokenizer:
- EDITOR
- metrics-field:update
summary: Update metric metadata
tags:
- metrics
@@ -21083,9 +21084,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Check if non-SigNoz metrics have been received
tags:
- metrics
@@ -21142,9 +21143,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics statistics
tags:
- metrics
@@ -21201,9 +21202,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics treemap
tags:
- metrics
@@ -28342,9 +28343,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- dashboard:list
- tokenizer:
- VIEWER
- dashboard:list
summary: Get metric dashboards (v2)
tags:
- metrics

View File

@@ -43,6 +43,25 @@ type Resource interface {
All resources are registered in [pkg/types/coretypes/registry_resource.go](/pkg/types/coretypes/registry_resource.go) using constructors like `NewResourceMetaResource(KindDashboard)` or `NewResourceTelemetryResource(KindLogs)`.
### Telemetry resources and metaresources
Almost every feature resource is one of these two types, and they answer different questions.
- A **telemetry resource** (`logs`, `traces`, `metrics`, `meter-metrics`, `audit-logs`) is the data itself. It only supports `read`. Its selector is not an ID but the query: the route wires a `TelemetryResourceDef` whose extractor turns the caller's filter into `<query_type>/<key>/<value>` IDs, and the check ladders each one through `<query_type>/<key>/*`, `<query_type>/*` and `*`. A grant such as `builder_query/signoz.workspace.key.id/key-a` therefore decides which rows a role may read; a query that does not pin a granted key resolves to `<query_type>/*` and is denied for key-scoped roles. Managed roles hold `*`.
- A **metaresource** (`dashboard`, `rule`, `logs-field`, ...) is an object that describes or configures the system. It supports whichever of `create`, `read`, `update`, `delete` and `list` the resource declares, its selectors are object IDs or `*`, and routes wire a `BasicResourceDef`. It says nothing about rows: `dashboard:read` on `*` does not grant the telemetry a dashboard's panels query.
The metrics explorer uses both on one surface:
| Route | Resource | Why |
|---|---|---|
| `POST /api/v2/metrics/stats` | `metrics:read` (telemetry) | Returns sample and series counts, i.e. the data. The selector comes from `filter.expression`, so a role scoped to a workspace key sees only its own samples. |
| `GET /api/v2/metrics` | `metrics-field:list` (meta) | Lists metric names, types and units: the catalogue, not the samples. |
| `POST /api/v2/metrics/metadata` | `metrics-field:update` (meta) | Edits a metric's type, unit and description. |
The two are independent: a role holding `metrics-field` `list` and `read` can browse the catalogue but cannot read a sample, and a role scoped to `builder_query/signoz.workspace.key.id/key-a` on `metrics` can query its samples but cannot list the catalogue.
For a new route, ask what it returns. Rows or aggregates over them go on the signal's telemetry resource with an extractor; anything that names, describes or configures telemetry goes on a metaresource.
### Selector
A `Selector` identifies *which* instance(s) of a resource a check is about — a UUID, a role name, or the wildcard `*`. A `SelectorFunc` maps the extracted resource ID to selectors at request time. Two prebuilt ones cover most routes ([pkg/types/coretypes/selector.go](/pkg/types/coretypes/selector.go)):
@@ -142,6 +161,7 @@ Because both paths go through the same middleware and the same `ResourceDef` dec
## What should I remember?
- Declare authz in the registries ([pkg/types/coretypes](/pkg/types/coretypes)), not in migrations — tuples for new organizations are derived from code at bootstrap.
- Telemetry resources are the data: `read` only, selector derived from the query. Metaresources are objects about the data: CRUD and list, ID or wildcard selectors. A route that returns rows or aggregates is telemetry; one that names or configures them is meta, as `metrics` versus `metrics-field` shows.
- A new kind never needs an OpenFGA schema change; only a new type does, and then **both** [pkg/authz/openfgaschema/base.fga](/pkg/authz/openfgaschema/base.fga) and [ee/authz/openfgaschema/base.fga](/ee/authz/openfgaschema/base.fga) must be updated together.
- Prefer `CheckResources` + `ResourceDef` over the coarse `ViewAccess`/`EditAccess`/`AdminAccess` gates for new routes.
- Use `WildcardSelector` for `create`/`list`, `IDSelector` for instance operations, and a custom `SelectorFunc` when the request ID is not the FGA selector.

View File

@@ -116,7 +116,7 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
reducedExpr := "toUInt64(1)"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {

View File

@@ -12,6 +12,7 @@
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
@@ -186,6 +187,49 @@
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
"button_discard_channel": "Discard",
"button_discard_confirm": "Discard",
"discard_changes_title": "Discard your changes?",
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
"tooltip_save_no_changes": "Nothing has changed yet.",
"entry_slack_field": "Field",
"entry_slack_action": "Action",
"entry_incidentio_metadata": "Entry",
"label_slack_field_title": "Title",
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
"label_slack_field_value": "Value",
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
"label_incidentio_metadata_key": "Key",
"label_incidentio_metadata_value": "Value",
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
"tooltip_slack_action_text": "The label on the button.",
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"tooltip_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA...",
"field_telegram_chat_id": "Chat ID",
"tooltip_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Message thread ID",
"tooltip_telegram_message_thread_id": "Posts into one message thread of a forum group. Leave empty to post in the group itself.",
"field_telegram_title": "Title",
"tooltip_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped.",
"field_telegram_message": "Message",
"tooltip_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL",
"tooltip_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"tooltip_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices."
}

View File

@@ -3291,6 +3291,7 @@ export enum CoretypesKindDTO {
'meter-metrics' = 'meter-metrics',
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
}
export enum CoretypesTypeDTO {
user = 'user',

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
LogViewMode,
FontSize,
OptionsMenuConfig,
} from 'container/OptionsMenu/types';
import {
Check,
ChevronLeft,

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export const REACT_QUERY_KEY = {
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,174 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
const { TextArea } = AntdInput;
/**
* The API models the chat and the topic as integers. An empty or half-typed box
* has to become `undefined`, not `NaN`: `omitBlank` keeps `NaN`, which
* serialises as `null` and the API rejects it. react-hook-form also runs this
* over the value already in the form, which arrives as a number, not a string.
*/
const asOptionalNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isNaN(value) ? undefined : value;
}
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
function TelegramSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="telegram-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('telegram_tip')}{' '}
<Typography.Link
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
>
{t('telegram_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_telegram_bot_token')}
tooltip={t('tooltip_telegram_bot_token')}
error={errors.botToken?.message}
required
>
<Input
{...register('botToken')}
aria-required
type="password"
data-testid="telegram-bot-token-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_chat_id')}
tooltip={t('tooltip_telegram_chat_id')}
error={errors.chatId?.message}
required
>
<Input
{...register('chatId', { setValueAs: asOptionalNumber })}
aria-required
type="number"
placeholder={t('placeholder_telegram_chat_id')}
data-testid="telegram-chat-id-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_message_thread_id')}
tooltip={t('tooltip_telegram_message_thread_id')}
error={errors.messageThreadId?.message}
>
<Input
{...register('messageThreadId', { setValueAs: asOptionalNumber })}
type="number"
data-testid="telegram-message-thread-id-textbox"
/>
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField
label={t('field_telegram_title')}
tooltip={t('tooltip_telegram_title')}
error={errors.title?.message}
>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="telegram-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_telegram_message')}
tooltip={t('tooltip_telegram_message')}
error={errors.message?.message}
>
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="telegram-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<FormCard title={t('section_advanced')}>
<FormField
label={t('field_telegram_api_url')}
tooltip={t('tooltip_telegram_api_url')}
error={errors.apiUrl?.message}
>
<Input {...register('apiUrl')} data-testid="telegram-api-url-textbox" />
</FormField>
<FormField
label={t('field_telegram_disable_notifications')}
tooltip={t('tooltip_telegram_disable_notifications')}
>
<Controller
control={control}
name="disableNotifications"
render={({ field }): JSX.Element => (
<Switch
color="primary"
textPlacement="right"
testId="telegram-disable-notifications-switch"
value={!!field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
</>
);
}
export default TelegramSettings;

View File

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

View File

@@ -1,6 +1,7 @@
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
@@ -20,6 +21,7 @@ import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import TelegramSettings from './Settings/Telegram';
import WebhookSettings from './Settings/Webhook';
import { Button } from '@signozhq/ui/button';
@@ -38,6 +40,7 @@ const CHANNEL_TYPE_OPTIONS = [
{ value: ChannelKind.jira, label: 'Jira' },
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
{ value: ChannelKind.incidentio, label: 'incident.io' },
{ value: ChannelKind.telegram, label: 'Telegram' },
];
function FormAlertChannels({
@@ -46,10 +49,12 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -80,6 +85,8 @@ function FormAlertChannels({
return <IncidentIOSettings />;
case ChannelKind.opsgenie:
return <OpsgenieSettings />;
case ChannelKind.telegram:
return <TelegramSettings />;
case ChannelKind.email:
return <EmailSettings />;
default:
@@ -111,7 +118,10 @@ function FormAlertChannels({
/>
</FormField>
<FormField label={t('field_send_resolved')}>
<FormField
label={t('field_send_resolved')}
tooltip={t('tooltip_send_resolved')}
>
<Controller
control={control}
name="sendResolved"
@@ -127,7 +137,11 @@ function FormAlertChannels({
/>
</FormField>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<FormField
label={t('field_channel_type')}
tooltip={t('tooltip_channel_type')}
htmlFor="channel-type"
>
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
@@ -144,30 +158,45 @@ function FormAlertChannels({
</fieldset>
<div className={styles.actions}>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<Button
variant="outlined"
color="secondary"
size="md"
testId="discard-channel-button"
prefix={<X size={14} />}
onClick={onDiscardHandler}
>
{t('button_discard_channel')}
</Button>
<div className={styles.actionGroup}>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
disabled={!hasChanges}
disabledTooltip={t('tooltip_save_no_changes')}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
</div>
</div>
</form>
</FormProvider>
@@ -180,12 +209,15 @@ interface FormAlertChannelsProps {
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
onDiscardHandler: () => void;
testingState: boolean;
savingState: boolean;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
hasChanges?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
@@ -195,6 +227,7 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

View File

@@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -121,22 +122,26 @@ describe('ListAlertRules — actions menu', () => {
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
let capturedRuleId: string | null = null;
server.use(
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
rest.post(
'http://localhost/api/v2/rules/:id/clone',
async (req, res, ctx) => {
capturedRuleId = req.params.id as string;
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'cloned-from-server',
alert: 'High CPU Alert - Copy',
},
status: 'success',
}),
);
},
),
);
renderListAlertRules();
@@ -145,9 +150,7 @@ describe('ListAlertRules — actions menu', () => {
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
expect(capturedRuleId).toBe('rule-1');
});
// The id from the server response round-trips into the navigate URL — this

View File

@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateListRules,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
@@ -62,10 +62,9 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
const handleClone = useCallback((): void => {
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
toast.promise(
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,8 @@ import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
import {
useAlertRuleClone,
useAlertRuleDelete,
useAlertRuleDuplicate,
useAlertRuleStatusToggle,
useAlertRuleUpdate,
} from 'pages/AlertDetails/hooks';
@@ -38,9 +38,7 @@ function AlertActionButtons({
const isDarkMode = useIsDarkMode();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertDuplicate } = useAlertRuleDuplicate({
alertDetails: alertDetails as unknown as AlertDef,
});
const { handleAlertClone } = useAlertRuleClone({ ruleId });
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
alertDetails: alertDetails as unknown as AlertDef,
@@ -72,10 +70,10 @@ function AlertActionButtons({
menuItems.push(
{
type: 'item',
value: 'duplicate-rule',
label: 'Duplicate',
value: 'clone-rule',
label: 'Clone',
prefix: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: handleAlertDuplicate,
onClick: handleAlertClone,
},
{
type: 'item',

View File

@@ -6,7 +6,7 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
cloneRuleByID,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
@@ -17,7 +17,6 @@ import {
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
useGetRuleHistoryTopContributors,
useListRules,
} from 'api/generated/services/rules';
import {
Querybuildertypesv5OrderDirectionDTO,
@@ -478,39 +477,32 @@ export const useAlertRuleStatusToggle = ({
return { handleAlertStateToggle };
};
export const useAlertRuleDuplicate = ({
alertDetails,
export const useAlertRuleClone = ({
ruleId,
}: {
alertDetails: AlertDef;
ruleId: string;
}): {
handleAlertDuplicate: () => void;
handleAlertClone: () => void;
} => {
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const params = useUrlQuery();
const { refetch } = useListRules({
query: { cacheTime: 0 },
});
const { showErrorModal } = useErrorModal();
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
(args: { data: AlertDef }) =>
createRule(toPostableRuleDTOFromAlertDef(args.data)),
const { mutate: cloneAlert } = useMutation(
[REACT_QUERY_KEY.CLONE_ALERT_RULE],
() => cloneRuleByID({ id: ruleId }),
{
onSuccess: async () => {
onSuccess: async (response) => {
notifications.success({
message: `Success`,
});
const { data: allAlertsData } = await refetch();
await invalidateListRules(queryClient);
const rules = allAlertsData?.data;
if (rules && rules.length > 0) {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
params.set(QueryParams.ruleId, response.data.id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
},
onError: (error) =>
showErrorModal(
@@ -519,15 +511,13 @@ export const useAlertRuleDuplicate = ({
},
);
const handleAlertDuplicate = (): void => {
const args = {
data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
};
duplicateAlert(args);
const handleAlertClone = (): void => {
cloneAlert();
};
return { handleAlertDuplicate };
return { handleAlertClone };
};
export const useAlertRuleUpdate = ({
alertDetails,
setAlertRuleName,

View File

@@ -107,7 +107,7 @@ export const alertRulesMocks = defineStoryMocks({
})),
),
rest.post('http://localhost/api/v2/rules', (_req, res, ctx) =>
rest.post('http://localhost/api/v2/rules/:id/clone', (_req, res, ctx) =>
actionResponse(values.cloneState, res, ctx, () => ({
status: 'success',
data: alertRulesResponse(1, {

View File

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

View File

@@ -90,13 +90,16 @@ export const SaveFailure: Story = {
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
untilLoaded,
),
const canvas = within(canvasElement);
// Save is off until the form is edited, so the failure is reached through
// a real change rather than an empty submit.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, untilLoaded),
'-edited',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Storybook forced channel failure');
},
};
@@ -178,3 +181,8 @@ export const JiraServiceManagementOps: Story = {
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
};
/** A Telegram channel, with its bot token masked behind the password field. */
export const Telegram: Story = {
args: { channelType: 'telegram' },
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { expect, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -82,6 +82,12 @@ export const NoCreate: Story = {
await expectDisabled(
await canvas.findByTestId('test-channel-button', undefined, PAGE_LOAD),
);
// Save is also off until the form is edited, so the edit comes first and
// what is left is the permission.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
false,
@@ -106,6 +112,39 @@ export const ReadOnly: Story = {
},
};
/**
* The same denial on a Telegram channel. Its fields are the kind the form gained
* last, including the only toggle a settings panel renders, so this is where a
* gap in the gating would show first.
*
* The switch is checked by behaviour, not by an attribute: it is a button inside
* the disabled fieldset, so it carries no `disabled` of its own and an
* attribute check would pass whether or not the browser actually turned it off.
*/
export const TelegramReadOnly: Story = {
args: { channelType: 'telegram', revoked: [UPDATE, CREATE, DELETE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(
await canvas.findByTestId(
'telegram-bot-token-textbox',
undefined,
PAGE_LOAD,
),
);
await expectDisabled(await canvas.findByTestId('telegram-chat-id-textbox'));
await expectDisabled(await canvas.findByTestId('telegram-message-textarea'));
const toggle = await canvas.findByTestId(
'telegram-disable-notifications-switch',
);
const before = toggle.getAttribute('aria-checked');
await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
},
};
/** No channel permission at all: the page is denied before the form exists. */
export const NoChannelAccess: Story = {
args: { revoked: NOTIFICATION_CHANNEL },
@@ -135,12 +174,13 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(save, false);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
},
};

View File

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

View File

@@ -58,9 +58,16 @@ export const ChannelTypeSelectOpen: Story = {
/** Mutation: saving without a channel name shows the form's required-field feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('save-channel-button'),
const canvas = within(canvasElement);
// Save stays off until the form is touched, so filling the webhook first is
// how a missing name is reached.
await userEvent.type(
await canvas.findByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Channel name is mandatory');
},
};
@@ -178,6 +185,38 @@ export const IncidentIO: Story = {
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Telegram$/);
},
};
/**
* Telegram's own required fields. The chat id is the form's first numeric field,
* and the API treats 0 as missing, so an empty box has to report the same error
* as a blank one rather than sending a zero.
*/
export const TelegramRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Telegram$/);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox'),
'alerts-telegram',
);
await userEvent.click(await canvas.findByTestId('save-channel-button'));
await screen.findByText(
'Bot token is mandatory for creating a telegram channel',
);
await screen.findByText(
'Chat ID is mandatory for creating a telegram channel',
);
},
};
/**
* The fields the API rejects a save without are marked before anything is
* submitted, so a long form does not have to be guessed at.
@@ -199,3 +238,38 @@ export const RequiredFields: Story = {
);
},
};
/**
* The type carried in the URL. Filtering the list by a type and pressing its
* create button lands here with `?kind=`, so the form opens on that type with
* nothing to pick.
*/
export const KindFromUrl: Story = {
args: { kindInUrl: 'jira' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Jira$/);
},
};
/**
* A `?kind=` the app does not have. The form opens on its own default rather
* than on an empty type, so a stale or hand-edited link still works.
*/
export const UnknownKindInUrl: Story = {
args: { kindInUrl: 'not-a-kind' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Slack$/);
},
};

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ const FIND = { timeout: 5000 };
interface PrefillCase {
kind: string;
spec: Record<string, unknown>;
fields: [string, string][];
fields: [string, string | number][];
}
const CASES: PrefillCase[] = [
@@ -186,6 +186,25 @@ const CASES: PrefillCase[] = [
['incidentio-description-textarea', 'incident description'],
],
},
{
kind: 'telegram',
spec: {
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 'telegram title',
message: 'telegram message',
apiUrl: 'https://api.telegram.org',
},
fields: [
['telegram-bot-token-textbox', '123456789:AA-token'],
['telegram-chat-id-textbox', -1001234567890],
['telegram-message-thread-id-textbox', 42],
['telegram-title-textarea', 'telegram title'],
['telegram-message-textarea', 'telegram message'],
['telegram-api-url-textbox', 'https://api.telegram.org'],
],
},
];
describe('ChannelForm prefill', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,7 @@ export const CHANNEL_TYPES = [
'jira',
'jsmops',
'incidentio',
'telegram',
] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
@@ -790,6 +791,22 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
// one of each so the repeating row editors render with content
fields: [
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
{
title: 'Service',
value: '{{ .CommonLabels.service_name }}',
short: false,
},
],
actions: [
{
type: 'button',
text: 'Runbook',
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
},
],
sendResolved: true,
},
webhook: {
@@ -847,6 +864,13 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
telegram: {
botToken: '123456789:AA-story-telegram-token',
chatId: -1001234567890,
title: '{{ .CommonLabels.alertname }}',
message: '{{ range .Alerts -}}{{ .Annotations.description }}\n{{ end }}',
sendResolved: true,
},
};
/** The v2 fetch by id, which is what the redesigned screens read. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,8 @@
"audit-logs",
"meter-metrics",
"logs-field",
"traces-field"
"traces-field",
"metrics-field"
],
"type": "string"
},

View File

@@ -8,7 +8,6 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/expr-lang/expr"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/factory"
@@ -219,8 +218,9 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
return matchedChannels, nil
}
env := r.convertLabelSetToEnv(ctx, set)
for _, route := range expressionRoutes {
evaluateExpr, err := r.evaluateExpr(ctx, route.Expression, set)
evaluateExpr, err := alertmanagertypes.EvalExpression(route.Expression, env)
if err != nil {
//nolint:sloglint
r.settings.Logger().WarnContext(ctx, "failed to evaluate route policy expression", errors.Attr(err), slog.String("rule.id", ruleID))
@@ -243,23 +243,3 @@ func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.Labe
}
return env
}
func (r *provider) evaluateExpr(ctx context.Context, expression string, labelSet model.LabelSet) (bool, error) {
env := r.convertLabelSetToEnv(ctx, labelSet)
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables())
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "error compiling route policy %s: %v", expression, err)
}
output, err := expr.Run(program, env)
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "error running route policy %s: %v", expression, err)
}
if boolVal, ok := output.(bool); ok {
return boolVal, nil
}
return false, errors.NewInternalf(errors.CodeInternal, "error in evaluating route policy %s: %v", expression, err)
}

View File

@@ -277,400 +277,6 @@ func TestProvider_ConcurrentAccess(t *testing.T) {
wg.Wait()
}
func TestProvider_EvaluateExpression(t *testing.T) {
provider := &provider{
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
}
tests := []struct {
name string
expression string
labelSet model.LabelSet
expected bool
}{
{
name: "simple equality check - match",
expression: `threshold.name == 'auth' && ruleId == 'rule1'`,
labelSet: model.LabelSet{
"threshold.name": "auth",
"ruleId": "rule1",
},
expected: true,
},
{
name: "simple equality check - match",
expression: `threshold.name = 'auth' AND ruleId = 'rule1'`,
labelSet: model.LabelSet{
"threshold.name": "auth",
"ruleId": "rule1",
},
expected: true,
},
{
name: "simple equality check - no match",
expression: `service == "payment"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "simple equality check - no match",
expression: `service = "payment"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with AND - both match",
expression: `service == "auth" && env == "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with AND - both match",
expression: `service = "auth" AND env = "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with AND - one doesn't match",
expression: `service == "auth" && env == "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with AND - one doesn't match",
expression: `service = "auth" AND env = "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with OR - one matches",
expression: `service == "payment" || env == "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with OR - one matches",
expression: `service = "payment" OR env = "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with OR - none match",
expression: `service == "payment" || env == "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with OR - none match",
expression: `service = "payment" OR env = "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "in operator - value in list",
expression: `service in ["auth", "payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: true,
},
{
name: "in operator - value in list",
expression: `service IN ["auth", "payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: true,
},
{
name: "in operator - value not in list",
expression: `service in ["payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "in operator - value not in list",
expression: `service IN ["payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "contains operator - substring match",
expression: `host contains "prod"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: true,
},
{
name: "contains operator - substring match",
expression: `host CONTAINS "prod"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: true,
},
{
name: "contains operator - no substring match",
expression: `host contains "staging"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: false,
},
{
name: "contains operator - no substring match",
expression: `host CONTAINS "staging"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: false,
},
{
name: "complex expression with parentheses",
expression: `(service == "auth" && env == "production") || critical == "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "complex expression with parentheses",
expression: `(service = "auth" AND env = "production") OR critical = "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "missing label key",
expression: `"missing_key" == "value"`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "missing label key",
expression: `"missing_key" = "value"`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "rule-based expression with threshold name and ruleId",
expression: `'threshold.name' == "high-cpu" && ruleId == "rule-123"`,
labelSet: model.LabelSet{
"threshold.name": "high-cpu",
"ruleId": "rule-123",
"service": "auth",
},
expected: false, //no commas
},
{
name: "rule-based expression with threshold name and ruleId",
expression: `'threshold.name' = "high-cpu" AND ruleId == "rule-123"`,
labelSet: model.LabelSet{
"threshold.name": "high-cpu",
"ruleId": "rule-123",
"service": "auth",
},
expected: false, //no commas
},
{
name: "alertname and ruleId combination",
expression: `alertname == "HighCPUUsage" && ruleId == "cpu-alert-001"`,
labelSet: model.LabelSet{
"alertname": "HighCPUUsage",
"ruleId": "cpu-alert-001",
"severity": "critical",
},
expected: true,
},
{
name: "alertname and ruleId combination",
expression: `alertname = "HighCPUUsage" AND ruleId = "cpu-alert-001"`,
labelSet: model.LabelSet{
"alertname": "HighCPUUsage",
"ruleId": "cpu-alert-001",
"severity": "critical",
},
expected: true,
},
{
name: "kubernetes namespace filtering",
expression: `k8s.namespace.name == "auth" && service in ["auth", "payment"]`,
labelSet: model.LabelSet{
"k8s.namespace.name": "auth",
"service": "auth",
"host": "k8s-node-1",
},
expected: true,
},
{
name: "kubernetes namespace filtering",
expression: `k8s.namespace.name = "auth" && service IN ["auth", "payment"]`,
labelSet: model.LabelSet{
"k8s.namespace.name": "auth",
"service": "auth",
"host": "k8s-node-1",
},
expected: true,
},
{
name: "migration expression format from SQL migration",
expression: `threshold.name == "HighCPUUsage" && ruleId == "rule-uuid-123"`,
labelSet: model.LabelSet{
"threshold.name": "HighCPUUsage",
"ruleId": "rule-uuid-123",
"severity": "warning",
},
expected: true,
},
{
name: "migration expression format from SQL migration",
expression: `threshold.name = "HighCPUUsage" && ruleId = "rule-uuid-123"`,
labelSet: model.LabelSet{
"threshold.name": "HighCPUUsage",
"ruleId": "rule-uuid-123",
"severity": "warning",
},
expected: true,
},
{
name: "case sensitive matching",
expression: `service == "Auth"`, // capital A
labelSet: model.LabelSet{
"service": "auth", // lowercase a
},
expected: false,
},
{
name: "case sensitive matching",
expression: `service = "Auth"`, // capital A
labelSet: model.LabelSet{
"service": "auth", // lowercase a
},
expected: false,
},
{
name: "numeric comparison as strings",
expression: `port == "8080"`,
labelSet: model.LabelSet{
"port": "8080",
},
expected: true,
},
{
name: "numeric comparison as strings",
expression: `port = "8080"`,
labelSet: model.LabelSet{
"port": "8080",
},
expected: true,
},
{
name: "quoted string with special characters",
expression: `service == "auth-service-v2"`,
labelSet: model.LabelSet{
"service": "auth-service-v2",
},
expected: true,
},
{
name: "quoted string with special characters",
expression: `service = "auth-service-v2"`,
labelSet: model.LabelSet{
"service": "auth-service-v2",
},
expected: true,
},
{
name: "boolean operators precedence",
expression: `service == "auth" && env == "prod" || critical == "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "boolean operators precedence",
expression: `service = "auth" AND env = "prod" OR critical = "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "nonexistent key OR check",
expression: `threshold.name = 'warning' OR ruleId = 'rule1'`,
labelSet: model.LabelSet{
"threshold.name": "warning",
},
expected: true,
},
{
name: "nonexistent key && check",
expression: `threshold.name = 'warning' && nonexistent = 'auth'`,
labelSet: model.LabelSet{
"threshold.name": "warning",
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := provider.evaluateExpr(context.Background(), tt.expression, tt.labelSet)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result, "Expression: %s", tt.expression)
})
}
}
func TestProvider_DeleteRoute(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()

View File

@@ -4,14 +4,16 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/gorilla/mux"
)
func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/metrics", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.ListMetrics),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.ListMetrics, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListMetrics",
Tags: []string{"metrics"},
@@ -25,13 +27,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/stats", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetStats),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricsStats",
Tags: []string{"metrics"},
@@ -44,13 +51,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/treemap", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetTreemap),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetTreemap, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricsTreemap",
Tags: []string{"metrics"},
@@ -63,13 +75,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricAttributes, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricAttributes",
Tags: []string{"metrics"},
@@ -83,13 +100,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricMetadata, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricMetadata",
Tags: []string{"metrics"},
@@ -103,13 +125,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
provider.authzMiddleware.EditAccess(provider.metricsExplorerHandler.UpdateMetricMetadata),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.UpdateMetricMetadata, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateMetricMetadata",
Tags: []string{"metrics"},
@@ -122,13 +149,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbUpdate)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/highlights", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricHighlights, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricHighlights",
Tags: []string{"metrics"},
@@ -142,13 +174,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceMetrics),
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/alerts", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricAlerts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricAlerts",
Tags: []string{"metrics"},
@@ -162,13 +199,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/metrics/dashboards", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboardsV2),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricDashboardsV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricDashboardsV2",
Tags: []string{"metrics"},
@@ -182,13 +224,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/inspect", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.InspectMetrics),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.InspectMetrics, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "InspectMetrics",
Tags: []string{"metrics"},
@@ -201,13 +248,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/onboarding", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetOnboardingStatus),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetOnboardingStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricsOnboardingStatus",
Tags: []string{"metrics"},
@@ -220,8 +272,13 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceMetrics),
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -3,10 +3,13 @@ package contextlinks
import (
"encoding/json"
"net/url"
"slices"
"strconv"
"strings"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -52,40 +55,66 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
return params
}
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
switch signal {
case telemetrytypes.SignalLogs:
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
case telemetrytypes.SignalTraces:
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
func PrepareRuleFilterExpression(condition *ruletypes.RuleCondition, signal telemetrytypes.Signal, labels map[string]string) (string, bool, error) {
var expressions []string
if condition != nil && condition.CompositeQuery != nil {
names, err := resolveSelectedQueryNames(condition)
if err != nil {
return "", false, err
}
switch signal {
case telemetrytypes.SignalLogs:
expressions = prepareFilterExpressions[qbtypes.LogAggregation](condition.CompositeQuery.Queries, names, signal, labels)
case telemetrytypes.SignalTraces:
expressions = prepareFilterExpressions[qbtypes.TraceAggregation](condition.CompositeQuery.Queries, names, signal, labels)
}
}
return "", nil, false
switch {
case len(expressions) == 0:
// a ClickHouse SQL alert has no builder query; its link filters on the labels alone
return PrepareFilterExpression(labels, "", nil), false, nil
case len(expressions) == 1:
return expressions[0], true, nil
case slices.Contains(expressions, ""):
// an unfiltered formula input matches everything, so the merged filter does too
return "", true, nil
}
// the explorer list views render one query only, so a formula's inputs merge into one filter
return "(" + strings.Join(expressions, ") OR (") + ")", true, nil
}
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
var q qbtypes.QueryBuilderQuery[T]
found := false
func resolveSelectedQueryNames(condition *ruletypes.RuleCondition) ([]string, error) {
selected := condition.SelectedQueryName()
for _, query := range condition.CompositeQuery.Queries {
if query.GetQueryName() != selected {
continue
}
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok {
return formula.ExtractReferencedQueryNames()
}
return []string{selected}, nil
}
return nil, nil
}
func prepareFilterExpressions[T any](queries []qbtypes.QueryEnvelope, names []string, signal telemetrytypes.Signal, labels map[string]string) []string {
var expressions []string
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
q = spec
found = true
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T])
// spec.Disabled is deliberately not checked: a formula's inputs are
// usually hidden in the UI but must still make it into the link
if !ok || spec.Signal != signal || !slices.Contains(names, spec.Name) {
continue
}
filterExpr := ""
if spec.Filter != nil {
filterExpr = spec.Filter.Expression
}
expressions = append(expressions, PrepareFilterExpression(labels, filterExpr, spec.GroupBy))
}
if !found || q.Signal != signal {
return "", nil, false
}
filterExpr := ""
if q.Filter != nil {
filterExpr = q.Filter.Expression
}
return filterExpr, q.GroupBy, true
return expressions
}

View File

@@ -4,12 +4,13 @@ import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuilderQueryForSignal(t *testing.T) {
func TestPrepareRuleFilterExpression(t *testing.T) {
logQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -19,62 +20,205 @@ func TestBuilderQueryForSignal(t *testing.T) {
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
},
}
hiddenLogQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
Disabled: true,
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'timeout'"},
},
}
unfilteredLogQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "C",
Signal: telemetrytypes.SignalLogs,
},
}
traceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Name: "D",
Signal: telemetrytypes.SignalTraces,
},
}
promQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
Spec: qbtypes.PromQuery{Name: "E"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Name: "F",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
errorRateFormula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F1", Expression: "B / A * 100"},
}
unfilteredFormula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F2", Expression: "A / C"},
}
brokenFormula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F3", Expression: "A +"},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
require.True(t, found)
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "service.name", groupBy[0].Name)
})
testCases := []struct {
name string
condition *ruletypes.RuleCondition
signal telemetrytypes.Signal
labels map[string]string
expectedError bool
expectedExpression string
expectedFound bool
}{
{
name: "SelectedLogsQuery_AmongMixedQueries",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
expectedFound: true,
},
{
name: "SelectedLogsQuery_LeavesOutUnselectedQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
expectedFound: true,
},
{
name: "SelectedFormula_MergesHiddenInputsInCompositeQueryOrder",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F1",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery, logQuery, errorRateFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "((body CONTAINS 'timeout') AND service.name='checkout') OR ((severity_text='ERROR') AND service.name='checkout')",
expectedFound: true,
},
{
name: "SelectedFormula_UnfilteredInputMatchesEverything",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F2",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, unfilteredLogQuery, unfilteredFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedExpression: "",
expectedFound: true,
},
{
name: "SelectedFormula_InvalidExpression",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F3",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, brokenFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedError: true,
},
{
name: "NoSelectedQuery_FallsBackToFormula",
condition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery, errorRateFormula}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "((severity_text='ERROR') AND service.name='checkout') OR ((body CONTAINS 'timeout') AND service.name='checkout')",
expectedFound: true,
},
{
name: "NoSelectedQuery_OnlyHiddenQueries",
condition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedExpression: "",
expectedFound: false,
},
{
name: "SelectedTracesQuery_WithoutFilter",
condition: &ruletypes.RuleCondition{
SelectedQuery: "D",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalTraces,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "service.name='checkout'",
expectedFound: true,
},
{
name: "SelectedAITraceQuery_CountsAsTraces",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, aiTraceQuery}},
},
signal: telemetrytypes.SignalTraces,
labels: map[string]string{"session.id": "s1"},
expectedExpression: "(trace.input_tokens>1000) AND session.id='s1'",
expectedFound: true,
},
{
name: "SelectedQuery_OfAnotherSignal_LabelsOnly",
condition: &ruletypes.RuleCondition{
SelectedQuery: "D",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "service.name='checkout'",
expectedFound: false,
},
{
name: "SelectedQuery_NotABuilderQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "E",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery}},
},
signal: telemetrytypes.SignalLogs,
labels: map[string]string{},
expectedExpression: "",
expectedFound: false,
},
{
name: "UnsupportedSignal_LabelsOnly",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery}},
},
signal: telemetrytypes.SignalMetrics,
labels: map[string]string{"service.name": "checkout"},
expectedExpression: "service.name='checkout'",
expectedFound: false,
},
}
t.Run("traces query without filter", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Empty(t, filterExpr)
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("no builder queries at all", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("unsupported signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
assert.False(t, found)
})
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
expression, found, err := PrepareRuleFilterExpression(testCase.condition, testCase.signal, testCase.labels)
if testCase.expectedError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.expectedExpression, expression)
assert.Equal(t, testCase.expectedFound, found)
})
}
}

View File

@@ -118,10 +118,47 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"cloudsql.googleapis.com/database/up",
"cloudsql.googleapis.com/database/instance_state",
"cloudsql.googleapis.com/database/replication/replica_lag",
"cloudsql.googleapis.com/database/cpu/utilization",
"cloudsql.googleapis.com/database/memory/utilization",
"cloudsql.googleapis.com/database/disk/utilization",
"cloudsql.googleapis.com/database/network/connections",
"cloudsql.googleapis.com/database/mysql/queries",
"cloudsql.googleapis.com/database/mysql/dml_operations_count",
"cloudsql.googleapis.com/database/mysql/threads",
"cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_reads_count",
"cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_read_requests_count",
"cloudsql.googleapis.com/database/mysql/slow_queries_count",
"cloudsql.googleapis.com/database/mysql/aborted_connects_count",
"cloudsql.googleapis.com/database/mysql/innodb/deadlocks_count",
"cloudsql.googleapis.com/database/mysql/innodb/row_lock_waits_count",
"cloudsql.googleapis.com/database/disk/read_ops_count",
"cloudsql.googleapis.com/database/disk/write_ops_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"cloudsql_database\" AND (logName:\"/logs/cloudsql.googleapis.com%2Fmysql\" OR (log_id(\"cloudaudit.googleapis.com/data_access\") AND protoPayload.request.@type=\"type.googleapis.com/google.cloud.sql.audit.v1.MysqlAuditEntry\"))"
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -75,6 +75,12 @@
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/io_time",
"unit": "Microseconds",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"unit": "Microseconds",
@@ -88,10 +94,43 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"cloudsql.googleapis.com/database/up",
"cloudsql.googleapis.com/database/cpu/utilization",
"cloudsql.googleapis.com/database/memory/utilization",
"cloudsql.googleapis.com/database/memory/usage",
"cloudsql.googleapis.com/database/disk/bytes_used",
"cloudsql.googleapis.com/database/postgresql/num_backends",
"cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
"cloudsql.googleapis.com/database/postgresql/transaction_count",
"cloudsql.googleapis.com/database/postgresql/deadlock_count",
"cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
"cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
"cloudsql.googleapis.com/database/postgresql/insights/aggregate/io_time",
"cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag"
]
},
"logs": {
"sinkFilter": "resource.type=\"cloudsql_database\" AND (logName:\"/logs/cloudsql.googleapis.com%2Fpostgres\" OR (log_id(\"cloudaudit.googleapis.com/data_access\") AND protoPayload.request.@type=\"type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry\"))"
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -52,10 +52,36 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"storage.googleapis.com/storage/v2/total_bytes",
"storage.googleapis.com/storage/v2/total_count",
"storage.googleapis.com/storage/v2/deleted_bytes",
"storage.googleapis.com/api/request_count",
"storage.googleapis.com/network/received_bytes_count",
"storage.googleapis.com/network/sent_bytes_count",
"storage.googleapis.com/authn/authentication_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"gcs_bucket\""
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -106,10 +106,45 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"compute.googleapis.com/instance/uptime_total",
"compute.googleapis.com/instance/cpu/utilization",
"compute.googleapis.com/guest/memory/bytes_used",
"compute.googleapis.com/guest/memory/percent_used",
"agent.googleapis.com/memory/percent_used",
"compute.googleapis.com/instance/disk/average_io_latency",
"compute.googleapis.com/instance/disk/read_bytes_count",
"compute.googleapis.com/instance/disk/write_bytes_count",
"compute.googleapis.com/instance/disk/read_ops_count",
"compute.googleapis.com/instance/disk/write_ops_count",
"compute.googleapis.com/instance/disk/performance_status",
"compute.googleapis.com/instance/network/received_bytes_count",
"compute.googleapis.com/instance/network/sent_bytes_count",
"compute.googleapis.com/instance/network/received_packets_count",
"compute.googleapis.com/instance/network/sent_packets_count",
"compute.googleapis.com/firewall/dropped_packets_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"gce_instance\" OR protoPayload.serviceName=\"compute.googleapis.com\""
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -106,10 +106,45 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"kubernetes.io/container/cpu/limit_utilization",
"kubernetes.io/container/cpu/request_utilization",
"kubernetes.io/container/memory/limit_utilization",
"kubernetes.io/container/memory/request_utilization",
"kubernetes.io/container/restart_count",
"kubernetes.io/pod/latencies/pod_first_ready",
"kubernetes.io/pod/network/received_bytes_count",
"kubernetes.io/pod/network/sent_bytes_count",
"kubernetes.io/pod/volume/utilization",
"kubernetes.io/node/cpu/allocatable_utilization",
"kubernetes.io/node/memory/allocatable_utilization",
"kubernetes.io/node/ephemeral_storage/used_bytes",
"kubernetes.io/node/ephemeral_storage/allocatable_bytes",
"kubernetes.io/node/network/received_bytes_count",
"kubernetes.io/node/network/sent_bytes_count",
"kubernetes.io/node/status_condition"
]
},
"logs": {
"sinkFilter": "resource.type=(\"k8s_container\" OR \"k8s_pod\" OR \"k8s_node\" OR \"k8s_cluster\" OR \"k8s_control_plane_component\" OR \"gke_cluster\" OR \"gke_nodepool\")"
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -76,10 +76,40 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"redis.googleapis.com/server/uptime",
"redis.googleapis.com/stats/memory/usage_ratio",
"redis.googleapis.com/stats/memory/system_memory_usage_ratio",
"redis.googleapis.com/stats/cpu_utilization_main_thread",
"redis.googleapis.com/clients/connected",
"redis.googleapis.com/stats/cache_hit_ratio",
"redis.googleapis.com/keyspace/keys",
"redis.googleapis.com/commands/usec_per_call",
"redis.googleapis.com/commands/calls",
"redis.googleapis.com/clients/blocked",
"redis.googleapis.com/stats/reject_connections_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"redis_instance\" OR (protoPayload.serviceName=\"redis.googleapis.com\" AND protoPayload.methodName:\".CloudRedis.\") OR (log_id(\"maintenance.googleapis.com/activity_log\") AND labels.activity_type_name:\"CloudRedisRollout\")"
}
}
},
"assets": {
"dashboards": [

View File

@@ -3,9 +3,11 @@ package implrulestatehistory
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/contextlinks"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -18,9 +20,10 @@ import (
// explorer with the context that produced it.
type relatedLinkBuilder struct {
alertType ruletypes.AlertType
signal telemetrytypes.Signal
evaluation ruletypes.Evaluation
filterExpr string
groupBy []qbtypes.GroupByKey
condition *ruletypes.RuleCondition
logger *slog.Logger
}
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
@@ -49,7 +52,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
builder := &relatedLinkBuilder{alertType: rule.AlertType}
builder := &relatedLinkBuilder{alertType: rule.AlertType, signal: signal, condition: rule.RuleCondition, logger: m.logger.With(slog.String("rule.id", ruleID))}
if rule.Evaluation != nil {
if evaluation, err := rule.Evaluation.GetEvaluation(); err == nil {
builder.evaluation = evaluation
@@ -63,10 +66,6 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
}
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
return builder
}
@@ -83,13 +82,19 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
// links returns the explorer query params for the given entry labels and time
// range.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
func (b *relatedLinkBuilder) links(ctx context.Context, labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
lbls := map[string]string{}
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
return rulestatehistorytypes.RelatedLinks{}
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
whereClause, _, err := contextlinks.PrepareRuleFilterExpression(b.condition, b.signal, lbls)
if err != nil {
b.logger.ErrorContext(ctx, "failed to prepare the filter for related links", errors.Attr(err))
return rulestatehistorytypes.RelatedLinks{}
}
switch b.alertType {
case ruletypes.AlertTypeLogs:

View File

@@ -2,6 +2,7 @@ package implrulestatehistory
import (
"context"
"log/slog"
"math"
"time"
@@ -15,10 +16,11 @@ import (
type module struct {
store rulestatehistorytypes.Store
ruleStore ruletypes.RuleStore
logger *slog.Logger
}
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore) rulestatehistory.Module {
return &module{store: store, ruleStore: ruleStore}
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore, logger *slog.Logger) rulestatehistory.Module {
return &module{store: store, ruleStore: ruleStore, logger: logger}
}
func (m *module) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]rulestatehistorytypes.RuleStateHistory, error) {
@@ -34,7 +36,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
for idx := range items {
start, end := builder.queryWindow(items[idx].UnixMilli)
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
items[idx].RelatedLinks = builder.links(ctx, items[idx].Labels, start, end)
}
}
@@ -60,7 +62,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
// span it too instead of a single evaluation window
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
for idx := range contributors {
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
contributors[idx].RelatedLinks = builder.links(ctx, contributors[idx].Labels, start, end)
}
}

View File

@@ -497,6 +497,9 @@ func bucketFormulaOutputAsHeatmap(tsData *qbtypes.TimeSeriesData, bucketing qbty
// to stay identical to them: a formula heatmap and a metric heatmap that
// disagreed here would put their counts in different buckets.
func calculateLinearValueUpperBound(bucketing qbtypes.HeatmapBucketing, value float64) float64 {
if value <= 0 {
return 0
}
if value > bucketing.MaxValue {
return math.Inf(1)
}

View File

@@ -85,9 +85,7 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
return req, nil
}
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
@@ -95,24 +93,19 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
whereClause, found, err := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalLogs, resultLabels.Map())
if err != nil {
r.logger.ErrorContext(ctx, "failed to prepare the filter for the logs link", errors.Attr(err))
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
}
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
@@ -120,18 +113,15 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
whereClause, found, err := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalTraces, resultLabels.Map())
if err != nil {
r.logger.ErrorContext(ctx, "failed to prepare the filter for the traces link", errors.Attr(err))
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
}

View File

@@ -159,7 +159,7 @@ func NewModules(
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore, providerSettings.Logger),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,

View File

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

View File

@@ -0,0 +1,162 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addMetricsFieldTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddMetricsFieldTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_metrics_field_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addMetricsFieldTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addMetricsFieldTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addMetricsFieldTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "metrics-field", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "metrics-field", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "metrics-field", "list"},
{authtypes.SigNozEditorRoleName, "metaresource", "metrics-field", "read"},
{authtypes.SigNozEditorRoleName, "metaresource", "metrics-field", "update"},
{authtypes.SigNozEditorRoleName, "metaresource", "metrics-field", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "metrics-field", "read"},
{authtypes.SigNozViewerRoleName, "metaresource", "metrics-field", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addMetricsFieldTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -4,12 +4,34 @@ import (
"strings"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/ast"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/errors"
)
var ErrCodeInvalidScopeExpression = errors.MustNewCode("invalid_scope_expression")
var ErrCodeInvalidExpression = errors.MustNewCode("invalid_expression")
const nilSafeMemberFunc = "__nilSafeMember"
// NilSafeMemberAccess rewrites every string-keyed member access a.b into a
// lookup that yields nil unless a is a map, so a dotted label that is missing,
// or whose prefix is a plain label, evaluates to nil instead of failing the run.
type NilSafeMemberAccess struct{}
func (NilSafeMemberAccess) Visit(node *ast.Node) {
member, ok := (*node).(*ast.MemberNode)
if !ok {
return
}
if _, ok := member.Property.(*ast.StringNode); !ok {
return
}
ast.Patch(node, &ast.CallNode{
Callee: &ast.IdentifierNode{Value: nilSafeMemberFunc},
Arguments: []ast.Node{member.Node, member.Property},
})
}
// ConvertLabelSetToEnv converts a label set into a map suitable for use as an
// expr environment. Dotted keys (e.g. "kubernetes.node") are expanded into
@@ -66,23 +88,28 @@ func ConvertLabelSetToEnv(lset model.LabelSet) (map[string]any, bool) {
return env, conflict
}
// EvalScopeExpression compiles and runs the expression against the provided
// labels. It returns (result, error). Callers should log the error and
// decide how to handle a failed evaluation (the maintenance muter treats a
// failure as "don't skip" so alerts pass through).
func EvalScopeExpression(expression string, lset model.LabelSet) (bool, error) {
env, _ := ConvertLabelSetToEnv(lset)
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables())
// EvalExpression compiles and runs the expression against env, as built by
// ConvertLabelSetToEnv. Callers should log the error and decide how to handle
// a failed evaluation (the maintenance muter treats a failure as "don't skip"
// so alerts pass through).
func EvalExpression(expression string, env map[string]any) (bool, error) {
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables(), expr.Function(nilSafeMemberFunc, nilSafeMember), expr.Patch(NilSafeMemberAccess{}))
if err != nil {
return false, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidScopeExpression, "compile scope expression %q", expression)
return false, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidExpression, "compile expression %q", expression)
}
output, err := expr.Run(program, env)
if err != nil {
return false, errors.Wrapf(err, errors.TypeInternal, ErrCodeInvalidScopeExpression, "run scope expression %q", expression)
return false, errors.Wrapf(err, errors.TypeInternal, ErrCodeInvalidExpression, "run expression %q", expression)
}
result, ok := output.(bool)
if !ok {
return false, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidScopeExpression, "scope expression %q returned non-bool value %T (%v)", expression, output, output)
return false, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidExpression, "expression %q returned non-bool value %T (%v)", expression, output, output)
}
return result, nil
}
func nilSafeMember(params ...any) (any, error) {
m, _ := params[0].(map[string]any)
key, _ := params[1].(string)
return m[key], nil
}

View File

@@ -5,10 +5,12 @@ import (
"testing"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvalScopeExpression(t *testing.T) {
cases := []struct {
func TestEvalExpression(t *testing.T) {
testCases := []struct {
name string
expression string
lset model.LabelSet
@@ -16,96 +18,419 @@ func TestEvalScopeExpression(t *testing.T) {
wantErr bool
}{
{
name: "equality match",
name: "Equality_Match",
expression: `env = "production"`,
lset: model.LabelSet{"env": "production"},
want: true,
},
{
name: "equality no match",
name: "Equality_NoMatch",
expression: `env = "production"`,
lset: model.LabelSet{"env": "staging"},
want: false,
},
{
name: "inequality match",
name: "Inequality_Match",
expression: `env != "production"`,
lset: model.LabelSet{"env": "staging"},
want: true,
},
{
name: "AND - both match",
name: "And_BothMatch",
expression: `env = "production" AND service = "api"`,
lset: model.LabelSet{"env": "production", "service": "api"},
want: true,
},
{
name: "AND - partial match",
name: "And_PartialMatch",
expression: `env = "production" AND service = "api"`,
lset: model.LabelSet{"env": "production", "service": "worker"},
want: false,
},
{
name: "OR - first matches",
name: "Or_FirstMatches",
expression: `env = "production" OR env = "staging"`,
lset: model.LabelSet{"env": "production"},
want: true,
},
{
name: "OR - second matches",
name: "Or_SecondMatches",
expression: `env = "production" OR env = "staging"`,
lset: model.LabelSet{"env": "staging"},
want: true,
},
{
name: "OR - none match",
name: "Or_NoneMatch",
expression: `env = "production" OR env = "staging"`,
lset: model.LabelSet{"env": "development"},
want: false,
},
{
name: "undefined label returns false",
name: "UndefinedLabel_ReturnsFalse",
expression: `env = "production"`,
lset: model.LabelSet{"service": "api"},
want: false,
},
{
name: "in list - present",
name: "MissingDottedLabel_ReturnsFalse",
expression: `service.name = "api"`,
lset: model.LabelSet{"env": "production"},
want: false,
},
{
name: "MissingDeepDottedLabel_ReturnsFalse",
expression: `k8s.namespace.name = "api"`,
lset: model.LabelSet{"env": "production"},
want: false,
},
{
name: "Or_MissingDottedLabelFirst_SecondMatches",
expression: `service.name = "api" OR http.status_code = "501"`,
lset: model.LabelSet{"http.status_code": "501"},
want: true,
},
{
name: "Or_FirstFails_MissingDottedLabelSecond",
expression: `env = "production" OR service.name = "api"`,
lset: model.LabelSet{"env": "staging"},
want: false,
},
{
name: "InList_Present",
expression: `env in ["production", "staging"]`,
lset: model.LabelSet{"env": "production"},
want: true,
},
{
name: "in list - absent",
name: "InList_Absent",
expression: `env in ["production", "staging"]`,
lset: model.LabelSet{"env": "development"},
want: false,
},
{
name: "invalid expression returns error",
name: "InvalidExpression_ReturnsError",
expression: `env =`,
lset: model.LabelSet{"env": "production"},
want: false,
wantErr: true,
},
{
name: "non-bool expression returns error",
name: "NonBoolExpression_ReturnsError",
expression: `env`,
lset: model.LabelSet{"env": "production"},
want: false,
wantErr: true,
},
{
name: "Equality_Match_ExprSyntax",
expression: `threshold.name == 'auth' && ruleId == 'rule1'`,
lset: model.LabelSet{
"threshold.name": "auth",
"ruleId": "rule1",
},
want: true,
},
{
name: "Equality_Match_QBSyntax",
expression: `threshold.name = 'auth' AND ruleId = 'rule1'`,
lset: model.LabelSet{
"threshold.name": "auth",
"ruleId": "rule1",
},
want: true,
},
{
name: "Equality_NoMatch_ExprSyntax",
expression: `service == "payment"`,
lset: model.LabelSet{
"service": "auth",
"env": "production",
},
want: false,
},
{
name: "And_BothMatch_ExprSyntax",
expression: `service == "auth" && env == "production"`,
lset: model.LabelSet{
"service": "auth",
"env": "production",
},
want: true,
},
{
name: "And_OneMismatch_ExprSyntax",
expression: `service == "auth" && env == "staging"`,
lset: model.LabelSet{
"service": "auth",
"env": "production",
},
want: false,
},
{
name: "Or_OneMatches_ExprSyntax",
expression: `service == "payment" || env == "production"`,
lset: model.LabelSet{
"service": "auth",
"env": "production",
},
want: true,
},
{
name: "Or_NoneMatch_ExprSyntax",
expression: `service == "payment" || env == "staging"`,
lset: model.LabelSet{
"service": "auth",
"env": "production",
},
want: false,
},
{
name: "In_ValueInList_Uppercase",
expression: `service IN ["auth", "payment", "notification"]`,
lset: model.LabelSet{
"service": "auth",
},
want: true,
},
{
name: "In_ValueNotInList_Uppercase",
expression: `service IN ["payment", "notification"]`,
lset: model.LabelSet{
"service": "auth",
},
want: false,
},
{
name: "Contains_SubstringMatch_Lowercase",
expression: `host contains "prod"`,
lset: model.LabelSet{
"host": "prod-server-01",
},
want: true,
},
{
name: "Contains_SubstringMatch_Uppercase",
expression: `host CONTAINS "prod"`,
lset: model.LabelSet{
"host": "prod-server-01",
},
want: true,
},
{
name: "Contains_NoSubstringMatch_Lowercase",
expression: `host contains "staging"`,
lset: model.LabelSet{
"host": "prod-server-01",
},
want: false,
},
{
name: "Contains_NoSubstringMatch_Uppercase",
expression: `host CONTAINS "staging"`,
lset: model.LabelSet{
"host": "prod-server-01",
},
want: false,
},
{
name: "Parentheses_Match_ExprSyntax",
expression: `(service == "auth" && env == "production") || critical == "true"`,
lset: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
want: true,
},
{
name: "Parentheses_Match_QBSyntax",
expression: `(service = "auth" AND env = "production") OR critical = "true"`,
lset: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
want: true,
},
{
name: "MissingLabelKey_NoMatch_ExprSyntax",
expression: `"missing_key" == "value"`,
lset: model.LabelSet{
"service": "auth",
},
want: false,
},
{
name: "MissingLabelKey_NoMatch_QBSyntax",
expression: `"missing_key" = "value"`,
lset: model.LabelSet{
"service": "auth",
},
want: false,
},
{
name: "QuotedThresholdName_NoMatch_ExprSyntax",
expression: `'threshold.name' == "high-cpu" && ruleId == "rule-123"`,
lset: model.LabelSet{
"threshold.name": "high-cpu",
"ruleId": "rule-123",
"service": "auth",
},
want: false, //no commas
},
{
name: "QuotedThresholdName_NoMatch_MixedSyntax",
expression: `'threshold.name' = "high-cpu" AND ruleId == "rule-123"`,
lset: model.LabelSet{
"threshold.name": "high-cpu",
"ruleId": "rule-123",
"service": "auth",
},
want: false, //no commas
},
{
name: "KubernetesNamespace_Match_ExprSyntax",
expression: `k8s.namespace.name == "auth" && service in ["auth", "payment"]`,
lset: model.LabelSet{
"k8s.namespace.name": "auth",
"service": "auth",
"host": "k8s-node-1",
},
want: true,
},
{
name: "KubernetesNamespace_Match_MixedSyntax",
expression: `k8s.namespace.name = "auth" && service IN ["auth", "payment"]`,
lset: model.LabelSet{
"k8s.namespace.name": "auth",
"service": "auth",
"host": "k8s-node-1",
},
want: true,
},
{
name: "MigrationFormat_Match_MixedSyntax",
expression: `threshold.name = "HighCPUUsage" && ruleId = "rule-uuid-123"`,
lset: model.LabelSet{
"threshold.name": "HighCPUUsage",
"ruleId": "rule-uuid-123",
"severity": "warning",
},
want: true,
},
{
name: "CaseSensitive_NoMatch_ExprSyntax",
expression: `service == "Auth"`, // capital A
lset: model.LabelSet{
"service": "auth", // lowercase a
},
want: false,
},
{
name: "CaseSensitive_NoMatch_QBSyntax",
expression: `service = "Auth"`, // capital A
lset: model.LabelSet{
"service": "auth", // lowercase a
},
want: false,
},
{
name: "OperatorPrecedence_Match_ExprSyntax",
expression: `service == "auth" && env == "prod" || critical == "true"`,
lset: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
want: true,
},
{
name: "OperatorPrecedence_Match_QBSyntax",
expression: `service = "auth" AND env = "prod" OR critical = "true"`,
lset: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
want: true,
},
{
name: "Or_MissingKey_OtherSideMatches",
expression: `threshold.name = 'warning' OR ruleId = 'rule1'`,
lset: model.LabelSet{
"threshold.name": "warning",
},
want: true,
},
{
name: "And_MissingKey_NoMatch",
expression: `threshold.name = 'warning' && nonexistent = 'auth'`,
lset: model.LabelSet{
"threshold.name": "warning",
},
want: false,
},
{
name: "Or_MissingDottedLabelSecond_OtherSideMatches",
expression: `http.status_code = '501' OR service.name = 'otel-demo'`,
lset: model.LabelSet{
"http.status_code": "501",
},
want: true,
},
{
name: "And_MissingDottedLabel_NoMatch",
expression: `service.name = 'otel-demo' AND http.status_code = '501'`,
lset: model.LabelSet{
"http.status_code": "501",
},
want: false,
},
{
name: "MissingLeafUnderExistingPrefix_NoMatch",
expression: `k8s.pod.name = 'checkout'`,
lset: model.LabelSet{
"k8s.node.name": "node-1",
},
want: false,
},
{
name: "PlainLabelUsedAsPrefix_IsNil",
expression: `deployment.environment = nil`,
lset: model.LabelSet{
"deployment": "blue",
},
want: true,
},
{
name: "Or_PlainLabelUsedAsPrefix_OtherSideMatches",
expression: `region.zone = 'us-east-1a' OR team = 'payments'`,
lset: model.LabelSet{
"region": "us-east-1",
"team": "payments",
},
want: true,
},
{
name: "BracketAccessOnDottedLabel_Match",
expression: `cloud["provider"] = 'aws'`,
lset: model.LabelSet{
"cloud.provider": "aws",
},
want: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := EvalScopeExpression(c.expression, c.lset)
if (err != nil) != c.wantErr {
t.Errorf("EvalScopeExpression(%q, %v) error = %v, wantErr %v", c.expression, c.lset, err, c.wantErr)
}
if got != c.want {
t.Errorf("EvalScopeExpression(%q, %v) = %v, want %v", c.expression, c.lset, got, c.want)
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
env, _ := ConvertLabelSetToEnv(testCase.lset)
got, err := EvalExpression(testCase.expression, env)
if testCase.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, testCase.want, got)
})
}
}

View File

@@ -149,7 +149,8 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model
}
if m.Scope != "" {
skip, err := EvalScopeExpression(m.Scope, lset)
env, _ := ConvertLabelSetToEnv(lset)
skip, err := EvalExpression(m.Scope, env)
if err != nil || !skip {
return false, err
}

View File

@@ -24,7 +24,19 @@ type GCPConnectionArtifact struct{}
type GCPIntegrationConfig struct{}
type GCPTelemetryCollectionStrategy struct{}
type GCPTelemetryCollectionStrategy struct {
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
}
type GCPMetricsCollectionStrategy struct {
MetricNames []string `json:"metricNames" required:"true" nullable:"false"`
}
type GCPLogsCollectionStrategy struct {
// Cloud Logging filter for the log sink, https://cloud.google.com/logging/docs/view/logging-query-language
SinkFilter string `json:"sinkFilter" required:"true"`
}
type GCPServiceConfig struct {
Logs *GCPServiceLogsConfig `json:"logs,omitempty" required:"false"`

View File

@@ -79,6 +79,7 @@ func (name Kind) Enum() []any {
KindMeterMetrics,
KindLogsField,
KindTracesField,
KindMetricsField,
}
}

View File

@@ -39,6 +39,7 @@ var Kinds = []Kind{
KindMeterMetrics,
KindLogsField,
KindTracesField,
KindMetricsField,
}
var (
@@ -80,4 +81,5 @@ var (
KindMeterMetrics = MustNewKind("meter-metrics")
KindLogsField = MustNewKind("logs-field")
KindTracesField = MustNewKind("traces-field")
KindMetricsField = MustNewKind("metrics-field")
)

View File

@@ -214,6 +214,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
// metrics-field — editor+admin update, viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
},
SigNozEditorRoleName: {
// cloud-integration — editor can read accounts
@@ -309,6 +313,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
// metrics-field — editor reads+updates
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
},
SigNozViewerRoleName: {
// cloud-integration — viewer can read accounts
@@ -370,6 +378,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{
// traces-field — viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
// metrics-field — viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
},
SigNozAnonymousRoleName: {
// public-dashboard — anonymous read

View File

@@ -39,6 +39,7 @@ var Resources = []Resource{
ResourceTelemetryResourceMeterMetrics,
ResourceMetaResourceLogsField,
ResourceMetaResourceTracesField,
ResourceMetaResourceMetricsField,
}
var (
@@ -80,4 +81,5 @@ var (
ResourceTelemetryResourceMeterMetrics = NewResourceTelemetryResource(KindMeterMetrics)
ResourceMetaResourceLogsField = NewResourceMetaResource(KindLogsField)
ResourceMetaResourceTracesField = NewResourceMetaResource(KindTracesField)
ResourceMetaResourceMetricsField = NewResourceMetaResource(KindMetricsField, VerbList, VerbRead, VerbUpdate)
)

View File

@@ -131,6 +131,22 @@ func (f QueryBuilderFormula) Validate() error {
return nil
}
func (f QueryBuilderFormula) ExtractReferencedQueryNames() ([]string, error) {
// parsed the way the evaluator parses it, so the names match what it resolves
expression, err := govaluate.NewEvaluableExpressionWithFunctions(f.Expression, EvalFuncs())
if err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse expression for formula query %q", f.Name)
}
var names []string
for _, variable := range expression.Vars() {
if !slices.Contains(names, variable) {
names = append(names, variable)
}
}
return names, nil
}
// small container to store the query name and index or alias reference
// for a variable in the formula expression
// read below for more details on aggregation references.

View File

@@ -242,19 +242,28 @@ func addHeatmapBucketsWithNoCountsForAggregation(aggBucket *AggregationBucket, b
}
indexes[i] = bucketing.calculateIndexOfUpperBound(upperBound)
}
lowest, highest := slices.Min(indexes), slices.Max(indexes)
lowestIndexOnAxis, highestIndexOnAxis := slices.Min(indexes), slices.Max(indexes)
// The lowest bucket reached needs the rung under it on the axis, or its own
// lower bound goes unstated and it reads as holding everything below. That
// one rung is enough: everything under it merges into the single leading
// bucket, which no series reached.
from := lowest - 1
if offset == 1 && bucketing.calculateUpperBoundAtIndex(from) <= aggBucket.Meta.Buckets[0] {
from = lowest
// Start one rung below the lowest bucket that holds a count, so its lower
// bound is on the axis. The axis may already begin with an empty bucket that
// states that bound, in which case nothing is added beneath it.
firstDenseIndex := lowestIndexOnAxis
for _, series := range aggBucket.Series {
for _, point := range series.Values {
for i := offset; i < len(point.Values) && i < len(aggBucket.Meta.Buckets); i++ {
if point.Values[i] != 0 {
firstDenseIndex = min(firstDenseIndex, indexes[i-offset]-1)
}
}
}
}
// The zero bucket is the floor, so no rung goes at or under it.
if offset == 1 && bucketing.calculateUpperBoundAtIndex(firstDenseIndex) <= aggBucket.Meta.Buckets[0] {
firstDenseIndex = lowestIndexOnAxis
}
denseUpperBounds := append([]float64{}, aggBucket.Meta.Buckets[:offset]...)
for index := from; index <= highest; index++ {
for index := firstDenseIndex; index <= highestIndexOnAxis; index++ {
denseUpperBounds = append(denseUpperBounds, bucketing.calculateUpperBoundAtIndex(index))
}
if len(denseUpperBounds) == len(aggBucket.Meta.Buckets) {
@@ -266,7 +275,7 @@ func addHeatmapBucketsWithNoCountsForAggregation(aggBucket *AggregationBucket, b
// lands where it came from.
shiftedTo := make([]int, len(aggBucket.Meta.Buckets))
for i, index := range indexes {
shiftedTo[i+offset] = index - from + offset
shiftedTo[i+offset] = index - firstDenseIndex + offset
}
overflowIndex := len(denseUpperBounds)

View File

@@ -126,6 +126,12 @@ type RuleCondition struct {
}
func (rc *RuleCondition) SelectedQueryName() string {
if rc.SelectedQuery != "" {
return rc.SelectedQuery
}
if rc.CompositeQuery == nil {
return ""
}
queryNames := map[string]struct{}{}
@@ -169,6 +175,9 @@ func (rc *RuleCondition) SelectedQueryName() string {
for k := range queryNames {
keys = append(keys, k)
}
if len(keys) == 0 {
return ""
}
sort.Strings(keys)
return keys[len(keys)-1]
}

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

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

View File

@@ -42,9 +42,9 @@
"@types/node": "^20.0.0",
"dotenv": "^16.0.0",
"eslint-plugin-playwright": "^2.10.2",
"oxfmt": "^0.41.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"oxfmt": "0.73.0",
"oxlint": "1.88.0",
"oxlint-tsgolint": "7.0.2004",
"typescript": "^5.0.0"
},
"engines": {

434
tests/e2e/pnpm-lock.yaml generated
View File

@@ -24,14 +24,14 @@ importers:
specifier: ^2.10.2
version: 2.10.2(eslint@10.2.1)
oxfmt:
specifier: ^0.41.0
version: 0.41.0
specifier: 0.73.0
version: 0.73.0
oxlint:
specifier: ^1.59.0
version: 1.61.0(oxlint-tsgolint@0.20.0)
specifier: 1.88.0
version: 1.88.0(oxlint-tsgolint@7.0.2004)
oxlint-tsgolint:
specifier: ^0.20.0
version: 0.20.0
specifier: 7.0.2004
version: 7.0.2004
typescript:
specifier: ^5.0.0
version: 5.9.3
@@ -88,260 +88,276 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@oxfmt/binding-android-arm-eabi@0.41.0':
resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==}
'@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.41.0':
resolution: {integrity: sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g==}
'@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.41.0':
resolution: {integrity: sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA==}
'@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.41.0':
resolution: {integrity: sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw==}
'@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.41.0':
resolution: {integrity: sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ==}
'@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.41.0':
resolution: {integrity: sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g==}
'@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.41.0':
resolution: {integrity: sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA==}
'@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.41.0':
resolution: {integrity: sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA==}
'@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.41.0':
resolution: {integrity: sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g==}
'@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.41.0':
resolution: {integrity: sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg==}
'@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.41.0':
resolution: {integrity: sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA==}
'@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.41.0':
resolution: {integrity: sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w==}
'@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.41.0':
resolution: {integrity: sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw==}
'@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.41.0':
resolution: {integrity: sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w==}
'@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.41.0':
resolution: {integrity: sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA==}
'@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.41.0':
resolution: {integrity: sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg==}
'@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.41.0':
resolution: {integrity: sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ==}
'@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.41.0':
resolution: {integrity: sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA==}
'@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.41.0':
resolution: {integrity: sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A==}
'@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.20.0':
resolution: {integrity: sha512-KKQcIHZHMxqpHUA1VXIbOG6chNCFkUWbQy6M+AFVtPKkA/3xAeJkJ3njoV66bfzwPHRcWQO+kcj5XqtbkjakoA==}
'@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.20.0':
resolution: {integrity: sha512-7HeVMuclGfG+NLZi2ybY0T4fMI7/XxO/208rJk+zEIloKkVnlh11Wd241JMGwgNFXn+MLJbOqOfojDb2Dt4L1g==}
'@oxlint-tsgolint/darwin-x64@7.0.2004':
resolution: {integrity: sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.20.0':
resolution: {integrity: sha512-zxhUwz+WSxE6oWlZLK2z2ps9yC6ebmgoYmjAl0Oa48+GqkZ56NVgo+wb8DURNv6xrggzHStQxqQxe3mK51HZag==}
'@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.20.0':
resolution: {integrity: sha512-/1l6FnahC9im8PK+Ekkx/V3yetO/PzZnJegE2FXcv/iXEhbeVxP/ouiTYcUQu9shT1FWJCSNti1VJHH+21Y1dg==}
'@oxlint-tsgolint/linux-x64@7.0.2004':
resolution: {integrity: sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.20.0':
resolution: {integrity: sha512-oPZ5Yz8sVdo7P/5q+i3IKeix31eFZ55JAPa1+RGPoe9PoaYVsdMvR6Jvib6YtrqoJnFPlg3fjEjlEPL8VBKYJA==}
'@oxlint-tsgolint/win32-arm64@7.0.2004':
resolution: {integrity: sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.20.0':
resolution: {integrity: sha512-4stx8RHj3SP9vQyRF/yZbz5igtPvYMEUR8CUoha4BVNZihi39DpCR8qkU7lpjB5Ga1DRMo2pHaA4bdTOMaY4mw==}
'@oxlint-tsgolint/win32-x64@7.0.2004':
resolution: {integrity: sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.61.0':
resolution: {integrity: sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==}
'@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.61.0':
resolution: {integrity: sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==}
'@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.61.0':
resolution: {integrity: sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==}
'@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.61.0':
resolution: {integrity: sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==}
'@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.61.0':
resolution: {integrity: sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==}
'@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.61.0':
resolution: {integrity: sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==}
'@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.61.0':
resolution: {integrity: sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==}
'@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.61.0':
resolution: {integrity: sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==}
'@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.61.0':
resolution: {integrity: sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==}
'@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.61.0':
resolution: {integrity: sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==}
'@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.61.0':
resolution: {integrity: sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==}
'@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.61.0':
resolution: {integrity: sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==}
'@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.61.0':
resolution: {integrity: sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==}
'@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.61.0':
resolution: {integrity: sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==}
'@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.61.0':
resolution: {integrity: sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==}
'@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.61.0':
resolution: {integrity: sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==}
'@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.61.0':
resolution: {integrity: sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==}
'@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.61.0':
resolution: {integrity: sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==}
'@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.61.0':
resolution: {integrity: sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==}
'@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]
@@ -546,24 +562,38 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
oxfmt@0.41.0:
resolution: {integrity: sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.20.0:
resolution: {integrity: sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ==}
hasBin: true
oxlint@1.61.0:
resolution: {integrity: sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==}
oxfmt@0.73.0:
resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.18.0'
'@astrojs/compiler-rs': ^0.4.0 || ^0.5.0
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
'@astrojs/compiler-rs':
optional: true
svelte:
optional: true
vite-plus:
optional: true
oxlint-tsgolint@7.0.2004:
resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==}
hasBin: true
oxlint@1.88.0:
resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=7.0.2003'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
@@ -607,8 +637,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
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}
type-check@0.4.0:
@@ -687,136 +717,136 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@oxfmt/binding-android-arm-eabi@0.41.0':
'@oxfmt/binding-android-arm-eabi@0.73.0':
optional: true
'@oxfmt/binding-android-arm64@0.41.0':
'@oxfmt/binding-android-arm64@0.73.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.41.0':
'@oxfmt/binding-darwin-arm64@0.73.0':
optional: true
'@oxfmt/binding-darwin-x64@0.41.0':
'@oxfmt/binding-darwin-x64@0.73.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.41.0':
'@oxfmt/binding-freebsd-x64@0.73.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.41.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.73.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.41.0':
'@oxfmt/binding-linux-arm-musleabihf@0.73.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.41.0':
'@oxfmt/binding-linux-arm64-gnu@0.73.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.41.0':
'@oxfmt/binding-linux-arm64-musl@0.73.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.41.0':
'@oxfmt/binding-linux-ppc64-gnu@0.73.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.41.0':
'@oxfmt/binding-linux-riscv64-gnu@0.73.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.41.0':
'@oxfmt/binding-linux-riscv64-musl@0.73.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.41.0':
'@oxfmt/binding-linux-s390x-gnu@0.73.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.41.0':
'@oxfmt/binding-linux-x64-gnu@0.73.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.41.0':
'@oxfmt/binding-linux-x64-musl@0.73.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.41.0':
'@oxfmt/binding-openharmony-arm64@0.73.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.41.0':
'@oxfmt/binding-win32-arm64-msvc@0.73.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.41.0':
'@oxfmt/binding-win32-ia32-msvc@0.73.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.41.0':
'@oxfmt/binding-win32-x64-msvc@0.73.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.20.0':
'@oxlint-tsgolint/darwin-arm64@7.0.2004':
optional: true
'@oxlint-tsgolint/darwin-x64@0.20.0':
'@oxlint-tsgolint/darwin-x64@7.0.2004':
optional: true
'@oxlint-tsgolint/linux-arm64@0.20.0':
'@oxlint-tsgolint/linux-arm64@7.0.2004':
optional: true
'@oxlint-tsgolint/linux-x64@0.20.0':
'@oxlint-tsgolint/linux-x64@7.0.2004':
optional: true
'@oxlint-tsgolint/win32-arm64@0.20.0':
'@oxlint-tsgolint/win32-arm64@7.0.2004':
optional: true
'@oxlint-tsgolint/win32-x64@0.20.0':
'@oxlint-tsgolint/win32-x64@7.0.2004':
optional: true
'@oxlint/binding-android-arm-eabi@1.61.0':
'@oxlint/binding-android-arm-eabi@1.88.0':
optional: true
'@oxlint/binding-android-arm64@1.61.0':
'@oxlint/binding-android-arm64@1.88.0':
optional: true
'@oxlint/binding-darwin-arm64@1.61.0':
'@oxlint/binding-darwin-arm64@1.88.0':
optional: true
'@oxlint/binding-darwin-x64@1.61.0':
'@oxlint/binding-darwin-x64@1.88.0':
optional: true
'@oxlint/binding-freebsd-x64@1.61.0':
'@oxlint/binding-freebsd-x64@1.88.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.61.0':
'@oxlint/binding-linux-arm-gnueabihf@1.88.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.61.0':
'@oxlint/binding-linux-arm-musleabihf@1.88.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.61.0':
'@oxlint/binding-linux-arm64-gnu@1.88.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.61.0':
'@oxlint/binding-linux-arm64-musl@1.88.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.61.0':
'@oxlint/binding-linux-ppc64-gnu@1.88.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.61.0':
'@oxlint/binding-linux-riscv64-gnu@1.88.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.61.0':
'@oxlint/binding-linux-riscv64-musl@1.88.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.61.0':
'@oxlint/binding-linux-s390x-gnu@1.88.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.61.0':
'@oxlint/binding-linux-x64-gnu@1.88.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.61.0':
'@oxlint/binding-linux-x64-musl@1.88.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.61.0':
'@oxlint/binding-openharmony-arm64@1.88.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.61.0':
'@oxlint/binding-win32-arm64-msvc@1.88.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.61.0':
'@oxlint/binding-win32-ia32-msvc@1.88.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.61.0':
'@oxlint/binding-win32-x64-msvc@1.88.0':
optional: true
'@playwright/test@1.57.0':
@@ -1016,61 +1046,61 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
oxfmt@0.41.0:
oxfmt@0.73.0:
dependencies:
tinypool: 2.1.0
tinypool: 2.2.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.41.0
'@oxfmt/binding-android-arm64': 0.41.0
'@oxfmt/binding-darwin-arm64': 0.41.0
'@oxfmt/binding-darwin-x64': 0.41.0
'@oxfmt/binding-freebsd-x64': 0.41.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.41.0
'@oxfmt/binding-linux-arm-musleabihf': 0.41.0
'@oxfmt/binding-linux-arm64-gnu': 0.41.0
'@oxfmt/binding-linux-arm64-musl': 0.41.0
'@oxfmt/binding-linux-ppc64-gnu': 0.41.0
'@oxfmt/binding-linux-riscv64-gnu': 0.41.0
'@oxfmt/binding-linux-riscv64-musl': 0.41.0
'@oxfmt/binding-linux-s390x-gnu': 0.41.0
'@oxfmt/binding-linux-x64-gnu': 0.41.0
'@oxfmt/binding-linux-x64-musl': 0.41.0
'@oxfmt/binding-openharmony-arm64': 0.41.0
'@oxfmt/binding-win32-arm64-msvc': 0.41.0
'@oxfmt/binding-win32-ia32-msvc': 0.41.0
'@oxfmt/binding-win32-x64-msvc': 0.41.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.20.0:
oxlint-tsgolint@7.0.2004:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.20.0
'@oxlint-tsgolint/darwin-x64': 0.20.0
'@oxlint-tsgolint/linux-arm64': 0.20.0
'@oxlint-tsgolint/linux-x64': 0.20.0
'@oxlint-tsgolint/win32-arm64': 0.20.0
'@oxlint-tsgolint/win32-x64': 0.20.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.61.0(oxlint-tsgolint@0.20.0):
oxlint@1.88.0(oxlint-tsgolint@7.0.2004):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.61.0
'@oxlint/binding-android-arm64': 1.61.0
'@oxlint/binding-darwin-arm64': 1.61.0
'@oxlint/binding-darwin-x64': 1.61.0
'@oxlint/binding-freebsd-x64': 1.61.0
'@oxlint/binding-linux-arm-gnueabihf': 1.61.0
'@oxlint/binding-linux-arm-musleabihf': 1.61.0
'@oxlint/binding-linux-arm64-gnu': 1.61.0
'@oxlint/binding-linux-arm64-musl': 1.61.0
'@oxlint/binding-linux-ppc64-gnu': 1.61.0
'@oxlint/binding-linux-riscv64-gnu': 1.61.0
'@oxlint/binding-linux-riscv64-musl': 1.61.0
'@oxlint/binding-linux-s390x-gnu': 1.61.0
'@oxlint/binding-linux-x64-gnu': 1.61.0
'@oxlint/binding-linux-x64-musl': 1.61.0
'@oxlint/binding-openharmony-arm64': 1.61.0
'@oxlint/binding-win32-arm64-msvc': 1.61.0
'@oxlint/binding-win32-ia32-msvc': 1.61.0
'@oxlint/binding-win32-x64-msvc': 1.61.0
oxlint-tsgolint: 0.20.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@3.1.0:
dependencies:
@@ -1102,7 +1132,7 @@ snapshots:
shebang-regex@3.0.0: {}
tinypool@2.1.0: {}
tinypool@2.2.0: {}
type-check@0.4.0:
dependencies:

View File

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

View File

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

View File

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

View File

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

View File

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

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