mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-22 10:00:42 +01:00
Compare commits
14 Commits
feat/funne
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407be7d267 | ||
|
|
089de88bf0 | ||
|
|
e5a39e3489 | ||
|
|
50b48649dd | ||
|
|
9fc6fbb9fa | ||
|
|
93d054ee02 | ||
|
|
fcb685bf37 | ||
|
|
e69d3fcc8c | ||
|
|
354214ca87 | ||
|
|
e3488c1592 | ||
|
|
574bfa62b8 | ||
|
|
ba6c4f83a6 | ||
|
|
ada6a20ea2 | ||
|
|
34e65a2533 |
180
.claude/skills/sbshot-visual-diff/SKILL.md
Normal file
180
.claude/skills/sbshot-visual-diff/SKILL.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
- metricsexplorer
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
@@ -41,7 +41,6 @@ linters:
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
key-naming-case: snake
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
24
frontend/src/components/BackHeader/BackHeader.module.scss
Normal file
24
frontend/src/components/BackHeader/BackHeader.module.scss
Normal 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;
|
||||
}
|
||||
56
frontend/src/components/BackHeader/BackHeader.tsx
Normal file
56
frontend/src/components/BackHeader/BackHeader.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
174
frontend/src/container/FormAlertChannels/Settings/Telegram.tsx
Normal file
174
frontend/src/container/FormAlertChannels/Settings/Telegram.tsx
Normal 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;
|
||||
@@ -24,6 +24,7 @@ describe('FormAlertChannels', () => {
|
||||
onTypeChangeHandler={jest.fn()}
|
||||
onSaveHandler={jest.fn()}
|
||||
onTestHandler={jest.fn()}
|
||||
onDiscardHandler={jest.fn()}
|
||||
testingState={false}
|
||||
savingState={false}
|
||||
editing
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"audit-logs",
|
||||
"meter-metrics",
|
||||
"logs-field",
|
||||
"traces-field"
|
||||
"traces-field",
|
||||
"metrics-field"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -263,6 +263,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
|
||||
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
162
pkg/sqlmigration/137_add_metrics_field_tuples.go
Normal file
162
pkg/sqlmigration/137_add_metrics_field_tuples.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -79,6 +79,7 @@ func (name Kind) Enum() []any {
|
||||
KindMeterMetrics,
|
||||
KindLogsField,
|
||||
KindTracesField,
|
||||
KindMetricsField,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
369
tests/e2e/drivers/channels.ts
generated
Normal 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';
|
||||
@@ -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
434
tests/e2e/pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
36
tests/e2e/tests/alerts/channels/create-each-type.spec.ts
Normal file
36
tests/e2e/tests/alerts/channels/create-each-type.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
43
tests/e2e/tests/alerts/channels/create.spec.ts
Normal file
43
tests/e2e/tests/alerts/channels/create.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
tests/e2e/tests/alerts/channels/delete.spec.ts
Normal file
24
tests/e2e/tests/alerts/channels/delete.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
39
tests/e2e/tests/alerts/channels/edit-each-type.spec.ts
Normal file
39
tests/e2e/tests/alerts/channels/edit-each-type.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
63
tests/e2e/tests/alerts/channels/list.spec.ts
Normal file
63
tests/e2e/tests/alerts/channels/list.spec.ts
Normal 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
Reference in New Issue
Block a user