Compare commits

...

7 Commits

Author SHA1 Message Date
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
Vinicius Lourenço
cf3662a7e5 fix(home): crash due to race condition on load motion (#12681)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

The issue seems to be caused by a weird race-condition that makes the
`AnimatePresence` context to be undefined, causing the `usePresence` to
crash.

The `motion/react-client` is meant to be used only by server components
of next.js, so updating the imports is safe and should fix the issue.

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

Closes https://github.com/SigNoz/engineering-pod/issues/5957
2026-10-08 19:29:19 +00:00
Vinicius Lourenço
18527e62c2 chore(storybook): replace story-shots scripts with sbshot (#13075)
#### Description

Dropping the custom scripts to take screenshot of storybook in favor of
sbshot, hosted at http://github.com/sigNoz/sbshot/
2026-10-08 18:30:34 +00:00
Vinicius Lourenço
0e2adf3329 ci(e2e): shard playwright across 4 runners and install browsers in background (#13077)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Two little optimizations:

- run the e2e CI shards since it's getting a little bigger (and slower)
- run parts of the steps in background
- this is something that was introduced recently by
https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/

Also adds a model pattern in `llm-o11y/llm-pricing.spec.ts`: the backend
rejects an empty `modelPattern` since #12995.

#### Additional Information

Latest run
([37721605759](https://github.com/SigNoz/signoz/actions/runs/37721605759))
against the last 10 successful single-job runs (Sep 7 to Oct 1):

| | Before (median) | After (4 shards) |
|---|---|---|
| Wall time | 26.5 min | 12.6 min |
| Playwright tests | 19.7 min | 3.7 to 5.9 min per shard |
| Job start to tests | 7.3 min | 6.3 to 7.8 min |
| Runner time | 26.5 min | 46.9 min |

- Sharding gives almost all of the gain. The previous run
([37717653047](https://github.com/SigNoz/signoz/actions/runs/37717653047))
took 12.3 min wall and 46.6 min runner time.
- Starting `playwright-browsers` after the image pulls cut its download
from 1.7 to 3.1 min down to about 50 s, but it now overlaps
`bring-up-stack`, which got about 30 s slower. Job start to tests stays
at about 7 min in both runs, so the background steps save about 20 s per
job.
2026-10-08 17:39:32 +00:00
27 changed files with 614 additions and 1467 deletions

View File

@@ -58,8 +58,10 @@ console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
Screenshots are worth taking for `Default` in both themes
(`&globals=theme:light`): text extraction does not catch an unstyled page.
Screenshots are worth taking for `Default` in both themes: text extraction does
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`.
## Gates

View File

@@ -1,294 +0,0 @@
---
name: storybook-visual-diff
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
---
# Storybook visual diff
Two scripts under `frontend/scripts`:
- `story-shots.mjs` — screenshots stories off a running Storybook dev server.
- `story-shots-diff.mjs` — pixel-diffs two runs and paints what moved.
Output goes to `frontend/.story-shots/` (gitignored), one directory per run.
## 0. Settle what is being compared, first
A diff is only worth taking when the two runs straddle something. Run twice over
the same tree and the answer is zero, or the noise floor: true, and useless.
So before starting a server, pin down four things. Whatever the prompt already
says, take it and do not ask again; ask only for what is genuinely missing, in
**one** `AskUserQuestion` call.
| To settle | Ask | Options |
| --- | --- | --- |
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
| Themes | "Which themes?" | dark · dark + light |
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
Skip a row when the prompt answers it, and skip the whole call when the prompt
answers all of it ("shoot the pods tooltips in both themes" needs no question).
Skip Read-out too whenever the job is *shoot only*, and take `none` for what it
says: shoot both sides, report both paths, run no comparison. When the prompt
says nothing at all, ask; a silent guess here burns ~6 min per sweep on the
wrong stories.
The job decides which loop below to run:
| Job | Loop |
| --- | --- |
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
| **noise floor** | two runs, same tree, diff. The number is the harness's floor, not a finding. |
| **config vs config** | same tree, two runs that differ only in flags: `--args`, `--clock`, `--width`, `--theme`, `--motion`. Filenames stay identical, so the pairs line up and the caption names what changed. |
## The loop
1. Capture the baseline **before touching anything**.
2. Capture it a second time and diff the two. That is the noise floor: anything
it reports is what the harness cannot hold still, and no conclusion about the
change may rest on those stories. Cheap on a handful of stories, ~6 min per
32, so on a wide sweep run it over the two or three stories the change is
aimed at instead of all of them.
3. Make the change.
4. Capture again into a third directory.
5. Diff, then read the tinted shot of the largest movers to judge the change.
## 1. One dev server, on a free port
`storybook dev` keys its Vite dep cache off the config dir, so two servers on the
same `-c` serve mismatched prebundles and every story dies with `Invalid hook
call`. Check what is already up first — port 6006 is often another repo's
Storybook, and its `index.json` then indexes the wrong stories:
```bash
for port in 6006 6007; do
curl -s -m 2 "http://localhost:$port/index.json" | head -c 60 && echo " <- $port"
done
```
Start the SigNoz one on a free port, from the repo's own binary so no package
manager shim is in the way:
```bash
cd frontend
nohup ./node_modules/.bin/storybook dev -p 6007 --no-open --quiet \
> "${TMPDIR:-/tmp}/signoz-storybook.log" 2>&1 &
```
It is ready when `curl -s localhost:6007/index.json` returns JSON whose
`entries` hold SigNoz story ids.
## 2. Capture
Playwright is not a frontend dependency. The script finds it in `tests/e2e`
(`pnpm -C tests/e2e install`, `@playwright/test` is enough) or in a global
install, and launches Playwright's own chromium, falling back to an installed
Chrome. Two escape hatches when that is not what a machine has:
```bash
export PLAYWRIGHT_MODULE=/path/to/playwright # a different install
export CHROME_PATH=/path/to/chrome # a specific browser binary
```
Then pick the stories. `--list` prints the selection without shooting anything:
```bash
# every tooltip story of every page
node scripts/story-shots.mjs .story-shots/baseline \
--port 6007 --title Pages/ --name tooltip --theme dark
# a handful of stories by id or by title/name substring, both themes
node scripts/story-shots.mjs .story-shots/baseline \
--port 6007 --stories pages-noz,dashboards/detail --theme dark,light
```
| Flag | Meaning |
| --- | --- |
| `--stories <match>` | id or `Title/Name` substring, repeatable or comma-separated. Omit for every story. |
| `--title <prefix>` | only titles starting with the prefix (`Pages/`, `Components/`) |
| `--name <match>` | only story names containing the match |
| `--theme dark,light` | one pass per theme; omit for the story's own default (dark) |
| `--args <k:v;k2:v2>` | arg overrides, Storybook's own `?args=` syntax, repeatable. A dotted value is dropped by Storybook itself, so map it to a slug inside the story's mocks |
| `--port` | dev server port, or `$SB_PORT` |
| `--width <px>` | the only fixed dimension, default 1680 |
| `--height <px>` | shortest the viewport may be, default 1200 |
| `--max-height <px>` | tallest it may grow to, default 8000 |
| `--grow <what>` | `scrollers` (default) grows the viewport until the page's own scrollers fit, `document` only follows the document height, `none` keeps `--height` |
| `--settle <ms>` | wait after the page goes quiet, default 1500 |
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
| `--flat` | write `<out>/<id>.png`, no theme directory |
| `--no-caption` | leave the caption band off the shots |
| `--list` | print the matched stories and exit |
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
rows) and how the run was configured (args, clock, width, height, grow, motion,
settle, ignore). Keep the flags identical between the two runs or the diff pairs
nothing.
Every shot carries the caption band described below, so a single screenshot says
what it is on its own. `--no-caption` leaves it off, and so does a machine
without ImageMagick (with a warning). The band never changes the shot's width
(long text wraps rather than widening the canvas) and its height is recorded, so
the diff crops it back off and never reports one caption against another. Two
runs whose captions are different heights still diff to zero. A story that never held still for two
identical frames is logged `busy` instead of `ok` — treat its diff as suspect.
Dark alone is enough while iterating on the harness; add `light` for the run you
report.
## 3. Diff
```bash
node scripts/story-shots-diff.mjs .story-shots/baseline .story-shots/capped .story-shots/diff
```
Prints `<changed pixels> <theme>/<story>.png`, largest first, and writes one
image per pair. Needs ImageMagick for PNG encode/decode (7's `magick`, or 6's
`convert`/`identify`/`montage`); the comparison itself is in the script.
| Flag | Meaning |
| --- | --- |
| `--mode green` | default. The after shot with the changed pixels painted over it, exactly the pixels that changed. What Chromatic shows. |
| `--mode green-parallel` | `previous \| current \| diff` in one image, each tile labelled above it, on a gutter inverted from the theme. The diff tile is the `green` one, so the after shot stays readable underneath. |
| `--mode red` | the after shot faded to 10%, changed pixels in red. A pixelmatch-style diff, easiest to read when the change is a thin edge. |
| `--mode red-parallel` | the same three tiles, with the `red` diff. Best when the change is a thin edge that the unfaded shot would swallow. |
| `--threshold <0..1>` | how far a pixel must move to count. Default 0.063, Chromatic's `diffThreshold`. |
| `--include-aa` | count antialiasing changes too. Off by default, as in Chromatic. |
| `--tint <#rrggbb>` | override the mode's colour. |
| `--no-caption` | drop the caption band. |
### The caption
Both scripts stamp a band on top of what they write: `story-shots.mjs` on each
shot, from the story and the run's own settings; `story-shots-diff.mjs` on each
diff, read out of the two runs' `shots.json`. It carries the story's
`Title/Name`, then its id, theme and `busy` flag, then the settings both runs
shared, each reading `key:value`. Whatever the two runs did **differently** goes
on the side it belongs to: under `previous` and `current` on the parallel tiles,
on two lines of the band otherwise. So a pair that differs only in `--args` says
so on its face, which is what makes several shots of one story tellable apart.
The shots' own bands are cropped off before comparing and before going into the
tiles, so nothing in the output is a diff of a caption. Type size follows the
image width, so it stays readable with the whole image viewed at fit-to-width;
the heading is set in an installed sans and the detail lines in a mono, falling
back to ImageMagick's default when neither is on the machine. Without a manifest
the band falls back to the file path, and a directory of captioned shots whose
`shots.json` is missing has nothing to crop by, so its captions do land in the
diff. Keep `shots.json` next to the shots.
### How the comparison works
Chromatic's own capture and diff run server-side — `chromatic-cli` uploads a
built Storybook and contains no capture or comparison code at all. What is public
is the parameter contract, and the numbers in it say what the comparison is:
`diffThreshold` defaults to `0.063` on a 0-1 scale, which is pixelmatch's
`threshold`, and `diffIncludeAntiAliasing` defaults to false, which is
pixelmatch's `includeAA: false`. So the script implements that comparison:
1. Both PNGs are read as raw RGBA through `magick … RGBA:-`.
2. Per pixel, the squared YIQ distance between the two colours (weights
`0.5053 / 0.299 / 0.1957`), compared against `35215 * threshold²` — 35215 is
the largest distance two 8-bit colours can have. Chroma is included, so a
colour swap at equal brightness still counts.
3. A pixel over the threshold is dropped when it is only antialiasing: it is the
darkest or lightest of its eight neighbours, and the other image has a pixel
around there doing the same job. This is what keeps a subpixel glyph edge from
reading as a change.
4. What survives is painted at full opacity, one output pixel per changed input
pixel. No dilation, no blobs — a one-pixel shift shows as a one-pixel line.
A pair whose shots are different sizes is compared over the overlap, and every
row and column that exists in only one of them counts as changed.
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
(new on the feature branch, renamed, retitled) has nothing to pair with and is
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
before reading the numbers.
## What makes a shot reproducible
Most of it is in the preview, not in the script, so a Chromatic build in the
cloud shoots the same page: `.storybook/preview-head.html` freezes the clock,
and `settleForCapture` (the preview's `afterEach`, which runs after `play`)
parks the animations and snaps the bottom-pinned lists. The script drives the
rest:
- **Storybook's own render phase is the readiness signal.** It waits for
`window.__STORYBOOK_PREVIEW__.storyRenders[].phase === 'finished'`, which is
reached only after the loaders, the decorators and the story's `play` are done.
A DOM check cannot see a `play` still running. (Storybook 10 spells the final
phase `finished`, not `completed`.)
- **Network quiescence, not `networkidle`.** react-query retries and msw keep
requests going after load, and a few stories hang a request by design, so the
wait is "no request for 600ms", capped at 15s.
- **The clock is frozen** (`2026-06-15T12:00:00Z`), by the preview itself. Chart windows, `4 mins ago`
labels and trial countdowns all derive from `now`; a live clock alone moved
8000 pixels on the dashboards list and redrew every chart axis.
- **Animations are parked on their last frame** by `html.sb-still`, a
zero-length single iteration with `forwards` fill, plus `prefers-reduced-
motion`. The Motion toolbar item (`still` by default) turns it off. An infinite
spinner is otherwise caught at a random angle.
- **`document.fonts.ready`**, because text reflows when a face lands late.
- **Lists pinned to their bottom are snapped onto it**, once by the preview and
again by the script after the page goes quiet. A virtuoso list settles a
few pixels short of the end depending on the order its items were measured in.
- **Two identical frames in a row**, because what a page is still waiting on is
often not observable from outside it.
- **`[data-shot-ignore]`, `[data-chromatic="ignore"]` and `--ignore <selector>`**
hide a region that cannot be held still; Chromatic excludes the same attribute
from its comparison.
- **The width is the only fixed dimension.** Chromatic's `viewports` are widths;
the height follows the page. `src/styles.scss` pins `html, body, #root` to
`height: 100%; overflow: hidden`, so the document never outgrows the viewport
and its height says nothing: what overflows are the shell's inner scrollers.
`--grow scrollers`, the default, grows the viewport until the tallest in-flow
scroller fits, so nothing is cut off and no scrollbar is left in the shot (the
dashboards list goes to 2226px in one round). Popups are skipped — they are out
of the flow, and a tall dropdown would otherwise drag the shot to a height
nothing on the page needs. A page that sizes a panel in `vh` grows its own
content as the viewport grows, so no height ever fits it and the rounds only
chase — `.alert-chart-container` is `57vh`, which puts Create Alert's fixed
point at 4344px with an empty band on top. Those pages are shot at `--height`
with their own scrollbar, which is what they look like in a browser, and the
log says `(viewport-sized content, stopped chasing Npx)`.
With all of that, 29 of the 32 page tooltip stories are byte-identical across
runs. The three that are not, and why:
| Story | Residual | Cause |
| --- | --- | --- |
| `kubernetes-pods--tooltips-in-options-panel` | ~13k px | 24 tooltips held open in an overlapping cluster; they portal to `body` in mount order, and the drawer's own tooltips mount before or after the list's depending on when their data lands, so overlapping tooltips stack differently. Panel geometry itself is stable. |
| `settings-role-editor--tooltips-in-json-editor` | ~2.5k px | monaco re-measures and lands one pixel off. |
| `traces-trace-details--tooltips` | ~800 px | same class, one row of the waterfall. |
Each is bimodal — two stable arrangements — so the same number reappears run
after run. Diff a story against itself before believing its number, and reach
for `--ignore` when a region cannot be settled.
## Gotchas
- **Zero pixels is a real answer.** A story whose tooltips are all short is
unaffected by a tooltip rule; it is not a broken capture.
- **The selector matters more than the rule.** A global rule on
`[data-slot='…']` only reaches design-system components. antd's own tooltips
(`.ant-tooltip-inner`, e.g. the Create Alert help popups) are untouched, which
is why some stories show no diff at all.
- **Global style overrides need `!important`.** `src/styles.scss` loads before
the design system injects its CSS-module styles at runtime, so a plain rule on
a `[data-slot='…']` element loses. A component-level `!important` of the same
specificity still wins over it — `PanelStatusPopover.module.scss` keeps its own
`max-width: 520px !important`.
- **A fresh context per story** is why a full sweep takes ~6 min for 32 stories.
Reusing one page loses the msw service worker re-registration race and stories
start failing after a few navigations.
- **Stories behind a hover, drawer or modal** only render what their `play`
reaches. If a state is missing from the shot, the story needs the `play`, not
the script.

1
.github/CODEOWNERS vendored
View File

@@ -296,4 +296,3 @@ go.mod @therealpandey
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
/.claude/skills/storybook-visual-diff/ @H4ad

View File

@@ -40,6 +40,7 @@ jobs:
matrix:
project:
- chromium
shard: [1, 2, 3, 4]
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
@@ -48,12 +49,33 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v4
# Images bring-up-stack would otherwise pull, at the tests/conftest.py
# defaults. A stale tag only loses the head start.
- name: pull-images
id: pull-images
background: true
run: |
printf '%s\n' \
node:22-bookworm \
golang:1.25-bookworm \
python:3.13-slim \
postgres:15 \
clickhouse/clickhouse-server:25.12.5 \
clickhouse/clickhouse-keeper:25.12.5 \
signoz/signoz-schema-migrator:v0.144.9 \
wiremock/wiremock:2.35.1-1 \
| xargs -P 0 -n 1 docker pull -q
- name: python
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: uv
uses: astral-sh/setup-uv@v4
- name: python-install
id: python-install
background: true
run: |
cd tests && uv sync
- name: node
uses: actions/setup-node@v4
with:
@@ -62,19 +84,9 @@ jobs:
uses: pnpm/action-setup@v6
with:
version: 10
- name: python-install
run: |
cd tests && uv sync
- name: pnpm-install
run: |
cd tests/e2e && pnpm install --frozen-lockfile
- name: playwright-browsers
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
# Restore-only: the cacheci workflow owns cache saves. Seeds the
# BuildKit cache mounts so the in-test image build is incremental.
- name: restore
@@ -99,6 +111,19 @@ jobs:
tar -xf /restored/pnpm-store.tar -C /pnpm/store
EOF
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: wait-stack-deps
wait: [pull-images, python-install]
# Started after the pulls so its download overlaps the CPU-bound
# image build instead of competing for the network.
- name: playwright-browsers
id: playwright-browsers
background: true
run: |
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
- name: bring-up-stack
run: |
cd tests && \
@@ -106,10 +131,12 @@ jobs:
--basetemp=./tmp/ \
-vv --reuse --with-web \
e2e/bootstrap/setup.py::test_setup
- name: wait-playwright-browsers
wait: playwright-browsers
- name: playwright-test
run: |
cd tests/e2e && \
pnpm playwright test --project=${{ matrix.project }}
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
- name: teardown-stack
if: always()
run: |
@@ -122,6 +149,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ matrix.project }}
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
path: tests/e2e/artifacts/
retention-days: 5

2
.gitignore vendored
View File

@@ -231,5 +231,7 @@ pyrightconfig.json
# dev
.dev/
.deepsec/
.deepsec.zip
.claude/worktrees/
.claude/settings.local.json

View File

@@ -24556,6 +24556,77 @@ paths:
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/clone:
post:
deprecated: false
description: This endpoint creates a new alert rule from the stored definition
of an existing one. The clone keeps every modeled field of the source, including
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
clone is owned by the caller. Cloning fails with 400 when the stored source
no longer passes create validation, for example when a channel it references
has been deleted.
operationId: CloneRuleByID
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone alert rule
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/history/filter_keys:
get:
deprecated: false

4
frontend/.gitignore vendored
View File

@@ -34,5 +34,5 @@ e2e/test-plan/user-preferences/
/storybook-static/
debug-storybook.log
# Storybook screenshot sweeps (scripts/story-shots.mjs)
/.story-shots/
# Storybook screenshots (sbshot)
/.sbshot/

View File

@@ -338,6 +338,10 @@
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
},
{
"name": "motion/react-client",
"message": "[motion] motion/react-client is for React Server Components only. Use 'motion/react' instead — it provides the same exports and works correctly in client-side SPAs."
}
]
}
@@ -578,17 +582,6 @@
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"
}
},
{
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
// algorithms that read worse when split up.
"files": [
"scripts/**"
],
"rules": {
"no-console": "off",
"sonarjs/cognitive-complexity": "off"
}
}
]
}

View File

@@ -1,7 +1,7 @@
/**
* Chromatic modes: one snapshot per entry, per story. The globals in a mode are
* Storybook's own, so `theme` is the toolbar's theme and the story renders the
* way it does locally. The width matches `scripts/story-shots.mjs` (`--width`),
* way it does locally. The width matches sbshot's default `--width`,
* so a cloud snapshot and a local shot frame the same page.
*/
export const allModes = {

View File

@@ -1,167 +0,0 @@
import { spawnSync } from 'node:child_process';
/**
* The caption band both story-shots.mjs and story-shots-diff.mjs stamp on their
* output, and the ImageMagick plumbing under it. A shot records the band's
* height in `shots.json` so the diff can crop it back off before comparing:
* otherwise two runs whose captions differ would report the caption as a change.
*/
export const CONFIG_KEYS = [
'args',
'clock',
'width',
'height',
'grow',
'motion',
'settle',
'ignore',
];
let tools;
const detect = () =>
(tools ??= {
seven: spawnSync('magick', ['-version']).status === 0,
six: spawnSync('convert', ['-version']).status === 0,
});
export const hasMagick = () => {
const { seven, six } = detect();
return seven || six;
};
export const requireMagick = () => {
if (hasMagick()) {
return;
}
console.error(
'ImageMagick not found. Install it (brew install imagemagick, apt install imagemagick).',
);
process.exit(1);
};
export const magick = (args, input) => {
// ImageMagick 6 has no `magick`: its tools are separate binaries.
const [command, ...rest] = detect().seven
? ['magick', ...args]
: ['identify', 'montage'].includes(args[0])
? args
: ['convert', ...args];
const result = spawnSync(command, rest, {
input,
maxBuffer: 1024 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command} ${rest.join(' ')}: ${result.stderr}`);
}
return result.stdout;
};
/**
* ImageMagick's built-in default is a serif that reads as a book, not as a
* screenshot label, so the band asks for what is installed: a sans for the
* heading, a mono for the lines that carry ids, args and numbers. An
* unrecognised name is fatal to `convert`, hence the check against the list it
* reports; a machine with none of them keeps the default.
*/
const FONTS = {
heading: [
'Helvetica-Bold',
'DejaVu-Sans-Bold',
'Liberation-Sans-Bold',
'Arial-Bold',
'Noto-Sans-Bold',
'DejaVu-Sans',
'Liberation-Sans',
],
body: [
'Menlo',
'DejaVu-Sans-Mono',
'Liberation-Mono',
'JetBrainsMono-NF-Regular',
'Courier',
],
};
let installed;
const fontArgs = (role) => {
installed ??= new Set(
[
...magick(['-list', 'font'])
.toString()
.matchAll(/^\s*Font:\s*(\S+)/gm),
].map(([, name]) => name),
);
const font = FONTS[role].find((name) => installed.has(name));
return font ? ['-font', font] : [];
};
/** Readable at fit-to-width, whatever the image is. */
export const pointsize = (width) =>
Math.min(Math.max(Math.round(width / 45), 24), 140);
// `label:` expands ImageMagick's own escapes and reads a file when the text
// starts with @, so story names and arg values go through neither.
export const bodyFont = () => fontArgs('body');
export const literal = (text) => text.replaceAll('%', '%%').replace(/^@/, ' @');
/** The gutter is the opposite of the theme, so the band keeps an edge. */
export const palette = (theme) =>
theme === 'light'
? { background: '#101014', foreground: '#f4f4f5' }
: { background: '#f4f4f5', foreground: '#101014' };
export const settingsLine = (config, keys = CONFIG_KEYS) =>
keys
.filter((key) => config?.[key])
.map((key) => `${key}:${config[key]}`)
.join(' ');
const heightOf = (file) => Number(magick(['identify', '-format', '%h', file]));
/**
* Writes `from` to `to` with `lines` above it, and returns how many rows that
* added — which is what a reader has to crop off to get the original back, so
* the band must never change the width. Each line is a `caption:` at the
* image's own width, wrapping instead of widening the canvas: a run whose
* caption is longer must still produce a shot the next run's shot pairs with.
* Type size follows the width, since a three-tile montage of 1680px shots is
* over 5000px wide and is read at fit-to-width.
*/
export const stamp = ({ lines, from, to, theme }) => {
const { background, foreground } = palette(theme);
const width = Number(magick(['identify', '-format', '%w', from]));
const heading = pointsize(width);
const before = heightOf(from);
const spacer = [
'-size',
`${width}x${Math.round(heading * 0.4)}`,
`xc:${background}`,
];
magick([
'-background',
background,
'-fill',
foreground,
'-gravity',
'center',
...spacer,
...lines.flatMap((line, index) => [
...fontArgs(index ? 'body' : 'heading'),
'-size',
`${width}x`,
'-pointsize',
String(index ? Math.round(heading * 0.62) : heading),
`caption:${literal(line)}`,
]),
...spacer,
from,
'-append',
to,
]);
return heightOf(to) - before;
};

View File

@@ -1,460 +0,0 @@
#!/usr/bin/env node
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import path from 'node:path';
import os from 'node:os';
import {
bodyFont,
CONFIG_KEYS,
literal,
magick,
palette,
pointsize,
requireMagick,
settingsLine,
stamp,
} from './story-shots-caption.mjs';
/**
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
* moved, per pair, largest first.
*
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
* distance from the baseline pixel is over `threshold` of the largest distance
* two colours can have, and pixels that are only antialiasing around an
* otherwise identical edge do not count. `threshold` is their `diffThreshold`
* and its default is theirs too.
*/
const MAX_YIQ_DELTA = 35_215;
const { values: opts, positionals } = parseArgs({
allowPositionals: true,
options: {
mode: { type: 'string', default: 'green' },
threshold: { type: 'string', default: '0.063' },
'include-aa': { type: 'boolean', default: false },
tint: { type: 'string', default: '' },
'no-caption': { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
});
const [baseDir, afterDir, outArg] = positionals;
const MODES = new Set(['green', 'green-parallel', 'red', 'red-parallel']);
if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
console.log(`usage: node scripts/story-shots-diff.mjs <baseline-dir> <after-dir> [diff-dir]
--mode green the after shot, changed pixels painted over it (default)
--mode green-parallel previous | current | green diff, side by side and labelled
--mode red the after shot faded out, changed pixels painted red
--mode red-parallel previous | current | red diff, side by side and labelled
--threshold <0..1> YIQ distance a pixel must move to count (default 0.063)
--include-aa count antialiasing changes too (default: ignore them)
--tint <#rrggbb> override the mode's highlight colour
--no-caption do not stamp the story and the run settings on top
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
process.exit(opts.help ? 0 : 1);
}
const outDir = outArg ?? path.join(path.dirname(baseDir), 'diff');
const threshold = Number(opts.threshold);
const maxDelta = MAX_YIQ_DELTA * threshold * threshold;
const highlight = hexToRgb(
opts.tint || (opts.mode.startsWith('green') ? '#00e05a' : '#ff003a'),
);
function hexToRgb(hex) {
const value = Number.parseInt(hex.replace('#', ''), 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}
requireMagick();
/**
* `top` rows are dropped: story-shots.mjs stamps a caption on its shots and
* records how tall it is, and a caption is not part of what the two runs are
* being compared on.
*/
const readRgba = (file, top = 0) => {
const [width, height] = magick(['identify', '-format', '%w %h', file])
.toString()
.split(' ')
.map(Number);
const data = magick([file, '-depth', '8', 'RGBA:-']);
return top > 0 && top < height
? { width, height: height - top, data: data.subarray(top * width * 4) }
: { width, height, data };
};
const writeRgba = ({ width, height, data }, file) =>
writeFile(
file,
magick(
['-depth', '8', '-size', `${width}x${height}`, 'RGBA:-', 'png:-'],
data,
),
);
/* The pixelmatch colour maths, which is what Chromatic's threshold is scaled to. */
const y = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
/** Squared YIQ distance, signed by which pixel is brighter. */
const colorDelta = (a, b, posA, posB, yOnly = false) => {
let r1 = a[posA];
let g1 = a[posA + 1];
let b1 = a[posA + 2];
const a1 = a[posA + 3];
let r2 = b[posB];
let g2 = b[posB + 1];
let b2 = b[posB + 2];
const a2 = b[posB + 3];
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) {
return 0;
}
// Anything translucent is composited over the same mid grey in both images,
// so a difference in alpha alone still registers.
if (a1 < 255) {
const alpha = a1 / 255;
r1 = r1 * alpha + 255 * (1 - alpha) * 0.5;
g1 = g1 * alpha + 255 * (1 - alpha) * 0.5;
b1 = b1 * alpha + 255 * (1 - alpha) * 0.5;
}
if (a2 < 255) {
const alpha = a2 / 255;
r2 = r2 * alpha + 255 * (1 - alpha) * 0.5;
g2 = g2 * alpha + 255 * (1 - alpha) * 0.5;
b2 = b2 * alpha + 255 * (1 - alpha) * 0.5;
}
const deltaY = y(r1, g1, b1) - y(r2, g2, b2);
if (yOnly) {
return deltaY;
}
const deltaI = i(r1, g1, b1) - i(r2, g2, b2);
const deltaQ = q(r1, g1, b1) - q(r2, g2, b2);
return (
0.5053 * deltaY * deltaY + 0.299 * deltaI * deltaI + 0.1957 * deltaQ * deltaQ
);
};
/**
* True when the pixel sits on an edge that is drawn one subpixel over rather
* than moved: it is the darkest or lightest of its neighbours in one image, and
* the other image has a pixel around there doing the same job.
*/
const antialiased = (a, x1, y1, width, height, b) => {
const x0 = Math.max(x1 - 1, 0);
const y0 = Math.max(y1 - 1, 0);
const x2 = Math.min(x1 + 1, width - 1);
const y2 = Math.min(y1 + 1, height - 1);
const pos = (y1 * width + x1) * 4;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
let min = 0;
let max = 0;
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 0;
for (let x = x0; x <= x2; x += 1) {
for (let yy = y0; yy <= y2; yy += 1) {
if (x === x1 && yy === y1) {
continue;
}
const delta = colorDelta(a, a, pos, (yy * width + x) * 4, true);
if (delta === 0) {
zeroes += 1;
if (zeroes > 2) {
return false;
}
} else if (delta < min) {
min = delta;
minX = x;
minY = yy;
} else if (delta > max) {
max = delta;
maxX = x;
maxY = yy;
}
}
}
if (min === 0 || max === 0) {
return false;
}
return (
(hasManySiblings(a, minX, minY, width, height) &&
hasManySiblings(b, minX, minY, width, height)) ||
(hasManySiblings(a, maxX, maxY, width, height) &&
hasManySiblings(b, maxX, maxY, width, height))
);
};
/** Whether the pixel has at least three identical neighbours. */
const hasManySiblings = (img, x1, y1, width, height) => {
const x0 = Math.max(x1 - 1, 0);
const y0 = Math.max(y1 - 1, 0);
const x2 = Math.min(x1 + 1, width - 1);
const y2 = Math.min(y1 + 1, height - 1);
const pos = (y1 * width + x1) * 4;
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
for (let x = x0; x <= x2; x += 1) {
for (let yy = y0; yy <= y2; yy += 1) {
if (x === x1 && yy === y1) {
continue;
}
const other = (yy * width + x) * 4;
if (
img[pos] === img[other] &&
img[pos + 1] === img[other + 1] &&
img[pos + 2] === img[other + 2] &&
img[pos + 3] === img[other + 3]
) {
zeroes += 1;
if (zeroes > 2) {
return true;
}
}
}
}
return false;
};
/**
* The changed pixels of the pair, painted over the after shot. The `red` modes
* fade the shot out first, the way a pixelmatch diff reads; the `green` ones
* leave it alone, the way Chromatic's does.
*/
const diffPair = (base, after, mode) => {
const width = Math.min(base.width, after.width);
const height = Math.min(base.height, after.height);
const out = Buffer.from(after.data);
const fade = !mode.startsWith('green');
let changed = 0;
if (fade) {
for (let pos = 0; pos < out.length; pos += 4) {
const grey = y(out[pos], out[pos + 1], out[pos + 2]);
const value = 255 + (grey - 255) * 0.1;
out[pos] = value;
out[pos + 1] = value;
out[pos + 2] = value;
out[pos + 3] = 255;
}
}
for (let row = 0; row < height; row += 1) {
for (let column = 0; column < width; column += 1) {
const basePos = (row * base.width + column) * 4;
const afterPos = (row * after.width + column) * 4;
const delta = colorDelta(base.data, after.data, basePos, afterPos);
if (Math.abs(delta) <= maxDelta) {
continue;
}
if (
!opts['include-aa'] &&
(antialiased(base.data, column, row, base.width, base.height, after.data) ||
antialiased(after.data, column, row, after.width, after.height, base.data))
) {
continue;
}
changed += 1;
out[afterPos] = highlight[0];
out[afterPos + 1] = highlight[1];
out[afterPos + 2] = highlight[2];
out[afterPos + 3] = 255;
}
}
// A shot that grew or shrank has no counterpart for the extra rows and
// columns, so all of them are a change.
const extra =
after.width * after.height -
width * height +
(base.width * base.height - width * height);
return {
data: out,
width: after.width,
height: after.height,
changed: changed + extra,
};
};
/**
* What each run was and how it was configured, from the `shots.json`
* story-shots.mjs leaves beside its output. A run shot before that existed, or
* a directory assembled by hand, simply gets no caption.
*/
const manifest = async (dir) => {
try {
return JSON.parse(await readFile(path.join(dir, 'shots.json'), 'utf8'));
} catch {
return null;
}
};
const [baseRun, afterRun] = await Promise.all([
manifest(baseDir),
manifest(afterDir),
]);
/** The settings the two runs disagree on: what a difference in the shots may be. */
const changedKeys = CONFIG_KEYS.filter(
(key) => (baseRun?.config?.[key] ?? '') !== (afterRun?.config?.[key] ?? ''),
);
const settings = (run, keys) => settingsLine(run?.config, keys);
const shotOf = (run, rel) =>
run?.shots?.find((shot) => shot.file === rel.split(path.sep).join('/'));
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
const withoutCaption = (file, { width, height }, top) =>
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
/** Story, then the settings both runs shared. One line each, widest font first. */
const header = (rel) => {
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
const shared = settings(
afterRun,
CONFIG_KEYS.filter((key) => !changedKeys.includes(key)),
);
return [
shot ? `${shot.title}/${shot.name}` : rel.replace(/\.png$/, ''),
[shot?.id ?? '', shot?.theme ?? '', shot?.status === 'busy' ? '(busy)' : '']
.filter(Boolean)
.join(' '),
shared,
].filter(Boolean);
};
/** A tile's own line: which side it is, and where its run differed. */
const sideLabel = (side, run) =>
[side, settings(run, changedKeys)].filter(Boolean).join(' ');
const captionLines = (rel, lines) =>
opts['no-caption'] ? [] : [...header(rel), ...lines].filter(Boolean);
const pngs = async (dir, prefix = '') => {
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const rel = path.join(prefix, entry.name);
if (entry.isDirectory()) {
files.push(...(await pngs(dir, rel)));
} else if (entry.name.endsWith('.png')) {
files.push(rel);
}
}
return files;
};
const results = [];
await mkdir(outDir, { recursive: true });
for (const rel of (await pngs(baseDir)).sort()) {
const afterFile = path.join(afterDir, rel);
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
let after;
try {
after = readRgba(afterFile, captionOf(afterRun, rel));
} catch {
console.error(`missing in after: ${rel}`);
continue;
}
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
const diff = diffPair(base, after, opts.mode);
const target = path.join(outDir, rel);
const parallel = opts.mode.endsWith('-parallel');
// With no tiles to label, a run's own settings go in the caption instead.
const caption = captionLines(
rel,
parallel || !changedKeys.length
? []
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
);
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
// The gutter is the opposite of the theme's own background, so the tiles and
// the caption keep an edge instead of bleeding into it.
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
const theme = shot?.theme ?? rel.split(path.sep)[0];
const { background, foreground } = palette(theme);
if (parallel) {
await writeRgba(diff, diffFile);
const tile = (label, file) => [
'(',
`label:${literal(label)}`,
file,
'-gravity',
'center',
'-append',
'-bordercolor',
background,
'-border',
'12',
')',
];
magick([
'-background',
background,
'-fill',
foreground,
...bodyFont(),
'-pointsize',
// The tiles end up side by side, so they are read at the montage's width.
String(Math.round(pointsize(after.width * 3) * 0.62)),
...tile(
sideLabel('previous', baseRun),
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
),
...tile(
sideLabel('current', afterRun),
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
),
...tile('diff', diffFile),
'-gravity',
'north',
'+append',
caption.length ? body : target,
]);
if (caption.length) {
stamp({ lines: caption, from: body, to: target, theme });
}
} else {
await writeRgba(diff, caption.length ? body : target);
if (caption.length) {
stamp({ lines: caption, from: body, to: target, theme });
}
}
results.push([diff.changed, rel]);
}
results
.sort((a, b) => b[0] - a[0])
.forEach(([changed, rel]) =>
console.log(`${String(changed).padStart(10)} ${rel}`),
);
console.error(`diffs in ${outDir}`);

View File

@@ -1,514 +0,0 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdir, rename, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { parseArgs } from 'node:util';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
import os from 'node:os';
import {
CONFIG_KEYS,
hasMagick,
settingsLine,
stamp,
} from './story-shots-caption.mjs';
/**
* The wall clock every shot is taken at, passed to the preview as `storyClock`.
* `.storybook/preview-head.html` freezes the same instant by itself, so a
* Chromatic build reads the clock this run does.
*/
const FROZEN_CLOCK = '2026-06-15T12:00:00.000Z';
const { values: opts, positionals } = parseArgs({
allowPositionals: true,
options: {
out: { type: 'string', short: 'o' },
stories: { type: 'string', multiple: true, default: [] },
title: { type: 'string', default: '' },
name: { type: 'string', default: '' },
theme: { type: 'string', multiple: true, default: [] },
args: { type: 'string', multiple: true, default: [] },
port: { type: 'string', default: process.env.SB_PORT ?? '6006' },
width: { type: 'string', default: '1680' },
height: { type: 'string', default: '1200' },
'max-height': { type: 'string', default: '8000' },
grow: { type: 'string', default: 'scrollers' },
settle: { type: 'string', default: '1500' },
clock: { type: 'string', default: FROZEN_CLOCK },
motion: { type: 'boolean', default: false },
ignore: { type: 'string', multiple: true, default: [] },
flat: { type: 'boolean', default: false },
'no-caption': { type: 'boolean', default: false },
list: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
});
const outDir = opts.out ?? positionals[0];
const themes = opts.theme.flatMap((value) => value.split(',')).filter(Boolean);
const storyArgs = opts.args.filter(Boolean).join(';');
if (opts.help || (!outDir && !opts.list)) {
console.log(`usage: node scripts/story-shots.mjs <out-dir> [options]
--stories <match> only stories whose id or title/name path contains <match>
(repeatable, comma-separated; default: every story)
--title <prefix> only stories whose title starts with <prefix>
--name <match> only stories whose name contains <match>
--theme <themes> themes to shoot, e.g. dark,light (default: story default)
--args <k:v;k2:v2> arg overrides, storybook's own ?args= syntax (repeatable).
A value containing a dot is dropped by storybook itself
--port <port> storybook dev server port (default 6006, or $SB_PORT)
--width <px> viewport width, the only fixed dimension (default 1680)
--height <px> shortest the viewport may be (default 1200)
--max-height <px> tallest the viewport may grow to (default 8000)
--grow <what> scrollers (default) grows the viewport until the page's
own scrollers fit, document only follows the document
height (a no-op on any page with the app shell), none
keeps --height
--settle <ms> wait after the page goes quiet (default 1500)
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
--motion keep animations and transitions running
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
--no-caption do not stamp the story and the run settings on the shot
--list print the matched stories and exit
Screenshots land in <out-dir>/<theme>/<story-id>.png, alongside a shots.json
recording what each shot is, how the run was configured, and how tall the
caption on it is. story-shots-diff.mjs reads that to crop the caption off before
comparing, so two runs never diff their own captions.
Captioning needs ImageMagick; without it the shots are written bare.
Playwright is looked up in tests/e2e, then in the global install; override with
PLAYWRIGHT_MODULE. The browser is playwright's own chromium, else an installed
Chrome; override with CHROME_PATH.`);
process.exit(opts.help ? 0 : 1);
}
const base = `http://localhost:${opts.port}`;
// `index.json` carries raw control characters from story jsdoc, so it is read as
// text rather than piped through anything that revalidates it.
const index = JSON.parse(await (await fetch(`${base}/index.json`)).text());
const matches = opts.stories
.flatMap((value) => value.split(','))
.filter(Boolean);
const stories = Object.values(index.entries)
.filter((entry) => {
if (entry.type !== 'story') {
return false;
}
if (opts.title && !entry.title.startsWith(opts.title)) {
return false;
}
if (
opts.name &&
!entry.name.toLowerCase().includes(opts.name.toLowerCase())
) {
return false;
}
if (!matches.length) {
return true;
}
const haystack = `${entry.id} ${entry.title}/${entry.name}`.toLowerCase();
return matches.some((match) => haystack.includes(match.toLowerCase()));
})
.sort((a, b) => a.id.localeCompare(b.id));
if (opts.list) {
stories.forEach((story) =>
console.log(`${story.id}\t${story.title}/${story.name}`),
);
console.log(`${stories.length} stories`);
process.exit(0);
}
if (!stories.length) {
console.error('no story matched');
process.exit(1);
}
const ignoreSelectors = opts.ignore
.flatMap((value) => value.split(','))
.map((value) => value.trim())
.filter(Boolean);
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
console.error(`--clock: not a date: ${opts.clock}`);
process.exit(1);
}
/**
* `[data-shot-ignore]` and `--ignore` hide what cannot be settled, the local
* half of Chromatic's `data-chromatic="ignore"`. Everything else the shot needs
* held still - the frozen clock, the parked animations, the lists snapped onto
* their bottom - is done by the preview itself, so a Chromatic build and a shot
* from here see the same page.
*/
const ignoreCss = (
ignore,
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
.map((selector) => `, ${selector}`)
.join('')} {
visibility: hidden !important;
}`;
/**
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
* or wherever `$PLAYWRIGHT_MODULE` points. `@playwright/test` re-exports
* `chromium`, so an e2e install alone is enough.
*/
const resolvePlaywright = () => {
const specifiers = process.env.PLAYWRIGHT_MODULE
? [process.env.PLAYWRIGHT_MODULE]
: ['playwright', '@playwright/test'];
const find = (roots) => {
for (const specifier of specifiers) {
for (const root of roots) {
try {
return createRequire(path.join(root, '-')).resolve(specifier);
} catch {
/* next candidate */
}
}
}
return null;
};
const local = find([
import.meta.dirname,
path.resolve(import.meta.dirname, '../../tests/e2e'),
]);
if (local) {
return local;
}
// `npm root -g` prints the global node_modules; resolution starts a level up.
const globalRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
const global =
globalRoot.status === 0 && find([path.dirname(globalRoot.stdout.trim())]);
if (global) {
return global;
}
console.error(
'playwright not found. Install it (pnpm -C tests/e2e install, or npm i -g playwright) or set PLAYWRIGHT_MODULE.',
);
return process.exit(1);
};
const pwModule = await import(pathToFileURL(resolvePlaywright()).href);
const pw = pwModule.chromium ? pwModule : pwModule.default;
console.log(
`${stories.length} stories x ${themes.length || 1} theme(s) -> ${outDir}`,
);
/**
* A playwright install carries no browser of its own, and the revision it wants
* is often not the one that was downloaded, so an installed Chrome is the
* fallback before giving up.
*/
const launch = async () => {
if (process.env.CHROME_PATH) {
return pw.chromium.launch({ executablePath: process.env.CHROME_PATH });
}
try {
return await pw.chromium.launch();
} catch (error) {
try {
return await pw.chromium.launch({ channel: 'chrome' });
} catch {
console.error(
`${error.message.split('\n')[0]}\nRun 'playwright install chromium' or set CHROME_PATH to a browser binary.`,
);
return process.exit(1);
}
}
};
const browser = await launch();
const failures = [];
const shots = [];
const runConfig = {
args: storyArgs,
clock: opts.clock,
width: opts.width,
height: opts.height,
grow: opts.grow,
motion: opts.motion ? 'live' : 'still',
settle: opts.settle,
ignore: ignoreSelectors.join(', '),
};
const captioning = !opts['no-caption'] && hasMagick();
if (!opts['no-caption'] && !captioning) {
console.error('ImageMagick not found: shots are written without a caption.');
}
const configLine = settingsLine(runConfig, CONFIG_KEYS);
for (const theme of themes.length ? themes : [null]) {
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
await mkdir(dir, { recursive: true });
if (theme) {
console.log(`\n[${theme}]`);
}
for (const story of stories) {
// A context per story: reusing one page loses the msw worker
// re-registration race after a few navigations and the story then dies on
// a missing worker.
const context = await browser.newContext({
viewport: { width: Number(opts.width), height: Number(opts.height) },
reducedMotion: opts.motion ? 'no-preference' : 'reduce',
});
const page = await context.newPage();
// react-query retries and msw both keep requests going long after load, so
// the settle waits on the page being quiet rather than on a fixed delay.
let inFlight = 0;
let lastActivity = Date.now();
page.on('request', () => {
inFlight += 1;
lastActivity = Date.now();
});
const done = () => {
inFlight = Math.max(inFlight - 1, 0);
lastActivity = Date.now();
};
page.on('requestfinished', done);
page.on('requestfailed', done);
// The height the rounds had reached when the page turned out to grow with
// the viewport, kept only to flag the story in the log.
let chasing = 0;
const url = new URL(`${base}/iframe.html`);
url.searchParams.set('viewMode', 'story');
url.searchParams.set('id', story.id);
// The preview owns the clock and the motion state, so both are asked for in
// the URL rather than injected here: a Chromatic build gets the defaults.
url.searchParams.set('storyClock', opts.clock);
const globals = [theme && `theme:${theme}`, opts.motion && 'motion:live']
.filter(Boolean)
.join(';');
if (globals) {
url.searchParams.set('globals', globals);
}
if (storyArgs) {
url.searchParams.set('args', storyArgs);
}
try {
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
// Storybook's own render phase is the readiness signal: it reaches
// `finished` only once the loaders, the decorators and the story's `play`
// are all done, which a DOM check cannot see. The dev server transforms
// each page module on first visit, so this is the slow wait.
await page.waitForFunction(
() =>
(window.__STORYBOOK_PREVIEW__?.storyRenders ?? []).some((render) =>
['finished', 'errored', 'aborted'].includes(render.phase),
) || document.body.classList.contains('sb-show-errordisplay'),
undefined,
{ timeout: 120_000 },
);
await page.addStyleTag({ content: ignoreCss(ignoreSelectors) });
if (!opts.motion) {
// Videos and GIFs are parked on their first frame, as Chromatic does.
await page.evaluate(() =>
document.querySelectorAll('video').forEach((video) => video.pause?.()),
);
}
// Text reflows when a webfont lands, so the shot waits for the faces the
// page asked for. Some stories keep a request open by design, hence the
// cap on the quiet wait rather than a plain networkidle.
await page.evaluate(() => document.fonts.ready);
const quietUntil = Date.now() + 15_000;
while (
Date.now() < quietUntil &&
(inFlight > 0 || Date.now() - lastActivity < 600)
) {
await page.waitForTimeout(200);
}
await page.waitForTimeout(Number(opts.settle));
// The width is the fixed dimension and the height follows the page, the
// way a Chromatic viewport does. `src/styles.scss` pins
// `html, body, #root` to `height: 100%; overflow: hidden`, so the
// document can never outgrow the viewport and its height says nothing
// about what is on the page: what overflows are the shell's inner
// scrollers. `scrollers` grows the viewport until the tallest of those
// fits, so nothing is cut off and no scrollbar is left in the shot.
// Growing changes the layout, hence the rounds. A page that sizes a panel
// in `vh` grows its own content as the viewport grows, so no height ever
// fits it and the rounds only chase: `.alert-chart-container` is `57vh`,
// which puts Create Alert's fixed point at 4344px with an empty band on
// top. Such a page is shot at `--height` with its own scrollbar instead,
// which is what it looks like in a browser.
if (opts.grow !== 'none') {
const maximum = Number(opts['max-height']);
const requested = Number(opts.height);
let height = requested;
let fits = false;
for (let round = 0; round < 3 && !fits; round += 1) {
const needed = Math.min(
maximum,
await page.evaluate((withScrollers) => {
const document_ = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
);
if (!withScrollers) {
return document_;
}
// Popups are skipped: they are out of the flow, and a tall
// dropdown or tooltip would otherwise drag the shot to a
// height nothing on the page itself needs.
const inFlow = (element) => {
for (
let node = element;
node && node !== document.documentElement;
node = node.parentElement
) {
const { position } = getComputedStyle(node);
if (position === 'fixed' || position === 'absolute') {
return false;
}
}
return true;
};
return [...document.querySelectorAll('*')].reduce((tallest, element) => {
const { overflowY } = getComputedStyle(element);
if (
!['auto', 'scroll', 'overlay'].includes(overflowY) ||
element.scrollHeight - element.clientHeight <= 1 ||
!inFlow(element)
) {
return tallest;
}
const box = element.getBoundingClientRect();
const above = box.top + window.scrollY;
const below = Math.max(0, document_ - (box.bottom + window.scrollY));
return Math.max(tallest, above + element.scrollHeight + below);
}, document_);
}, opts.grow === 'scrollers'),
);
fits = needed <= height;
if (fits) {
break;
}
height = Math.ceil(needed);
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}
if (!fits && height !== requested) {
chasing = height;
height = requested;
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}
}
// The preview snapped its bottom-pinned lists at `afterEach`, before the
// page went quiet; a virtuoso list is usually still measuring then.
await page.evaluate(() => window.__signozSnapPinnedScrollers?.());
// A page that is still moving — a list scrolling itself to the bottom, a
// monaco editor re-measuring, a tooltip being repositioned — is shot
// twice in a row until two frames come back identical, since what the
// page is waiting on is not observable from here.
let shot = await page.screenshot();
let stable = false;
for (let attempt = 0; attempt < 8 && !stable; attempt += 1) {
await page.waitForTimeout(400);
const next = await page.screenshot();
stable = next.equals(shot);
shot = next;
}
const file = path.join(dir, `${story.id}.png`);
await writeFile(file, shot);
// The band goes on the shot itself so a single screenshot says what it
// is, and its height is recorded so a diff can take it back off.
let caption = 0;
if (captioning) {
const temporary = path.join(
os.tmpdir(),
`story-shots-caption-${process.pid}.png`,
);
caption = stamp({
lines: [
`${story.title}/${story.name}`,
[story.id, theme ?? 'default', stable ? '' : '(busy)']
.filter(Boolean)
.join(' '),
configLine,
].filter(Boolean),
from: file,
to: temporary,
theme: theme ?? 'dark',
});
await rename(temporary, file);
}
shots.push({
file: path.posix.join(
opts.flat ? '' : (theme ?? 'default'),
`${story.id}.png`,
),
id: story.id,
title: story.title,
name: story.name,
theme: theme ?? 'default',
status: stable ? 'ok' : 'busy',
caption,
});
console.log(
` ${stable ? 'ok ' : 'busy'} ${story.id}${
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
}`,
);
} catch (error) {
failures.push(`${theme ?? 'default'}/${story.id}`);
console.log(` FAIL ${story.id}: ${error.message.split('\n')[0]}`);
} finally {
await context.close();
}
}
}
await browser.close();
// The diff script captions its output from this, so the run's own settings sit
// next to the shots they produced rather than only in the shell history.
await writeFile(
path.join(outDir, 'shots.json'),
`${JSON.stringify({ config: runConfig, shots }, null, '\t')}\n`,
);
if (failures.length) {
console.error(`\n${failures.length} failed: ${failures.join(', ')}`);
process.exit(1);
}

View File

@@ -18,6 +18,8 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller. Cloning fails with 400 when the stored source no longer passes create validation, for example when a channel it references has been deleted.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys

View File

@@ -14202,6 +14202,17 @@ export type PatchRuleByID200 = {
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByID201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};

View File

@@ -32,8 +32,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import history from 'lib/history';
import cloneDeep from 'lodash-es/cloneDeep';
import { AnimatePresence } from 'motion/react';
import * as motion from 'motion/react-client';
import { AnimatePresence, motion } from 'motion/react';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';

View File

@@ -524,3 +524,18 @@ without it the story re-renders the tree the previous walk left behind and the
control looks dead. The endpoint that settles a transition between two steps then
needs a plain resolver rather than `response.json`, or the Data control on
`loading` strands the walk halfway.
## Screenshots
To check what a CSS or component change does before Chromatic runs on the PR,
use [sbshot](https://github.com/SigNoz/sbshot). It shoots the stories, diffs two
runs the way Chromatic does, and opens a viewer on the changes. From `frontend/`:
```bash
sbshot capture . --name-run before --title Pages/ --theme dark,light
# make the change
sbshot capture . --name-run after --title Pages/ --theme dark,light
sbshot diff before after --ui
```
Output goes to `.sbshot/`, which is gitignored.

View File

@@ -21,7 +21,7 @@ const snapPinnedScrollers = (): void => {
/**
* Runs after the story's `play`, which is where both capture stacks take the
* picture: Chromatic snapshots there, and `scripts/story-shots.mjs` waits for
* picture: Chromatic snapshots there, and sbshot waits for
* the same render phase. Only what Chromatic cannot do for itself lives here -
* it already pauses animations, videos and GIFs, and waits for the network to
* go quiet.

View File

@@ -71,6 +71,20 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller. Cloning fails with 400 when the stored source no longer passes create validation, for example when a channel it references has been deleted.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},

View File

@@ -7,6 +7,7 @@ type Handler interface {
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)
CloneRuleByID(http.ResponseWriter, *http.Request)
UpdateRuleByID(http.ResponseWriter, *http.Request)
DeleteRuleByID(http.ResponseWriter, *http.Request)
PatchRuleByID(http.ResponseWriter, *http.Request)

View File

@@ -29,6 +29,9 @@ type Ruler interface {
// model to store structured data.
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
// CloneRule creates a new rule from the stored definition of id, with a " - Copy" suffixed name.
CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
// EditRule replaces the rule identified by id with the given JSON string.
// TODO: same as CreateRule — accept PostableRule instead of raw string.
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error

View File

@@ -105,6 +105,25 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
rule, err := handler.ruler.CloneRule(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -2,10 +2,12 @@ package signozruler
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -18,6 +20,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -128,6 +131,39 @@ func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*rule
return provider.manager.CreateRule(ctx, ruleStr)
}
func (provider *provider) CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
storedRule, err := provider.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return nil, err
}
postableRule, err := storedRule.ToPostableRuleForCloning()
if err != nil {
return nil, err
}
if err := postableRule.Validate(); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "source rule %s is not valid", id.StringValue())
}
body, err := json.Marshal(postableRule)
if err != nil {
return nil, err
}
return provider.manager.CreateRule(ctx, string(body))
}
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
return provider.manager.EditRule(ctx, ruleStr, id)
}

View File

@@ -3,6 +3,9 @@ package ruletypes
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/types"
@@ -10,6 +13,8 @@ import (
"github.com/uptrace/bun"
)
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
type StorableRule struct {
// The alias must stay rule: the list filter compiler emits rule.<col> refs.
bun.BaseModel `bun:"table:rule,alias:rule"`
@@ -48,6 +53,15 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
return stats
}
func (rule *StorableRule) ToPostableRuleForCloning() (*PostableRule, error) {
postable := &PostableRule{}
if err := json.Unmarshal([]byte(rule.Data), postable); err != nil {
return nil, err
}
postable.AlertName = nextCloneAlertName(postable.AlertName)
return postable, nil
}
// RuleAlert represents an alert associated with a rule, used when filtering by metric name.
type RuleAlert struct {
AlertName string
@@ -72,3 +86,19 @@ type RuleStore interface {
UpdateRuleView(context.Context, *RuleView) error
DeleteRuleView(context.Context, valuer.UUID, valuer.UUID) error
}
func nextCloneAlertName(name string) string {
base, count := name, 0
if m := cloneCopySuffixRegex.FindStringSubmatch(name); m != nil {
base = m[1]
count = 1
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
if count++; count > 1 {
return fmt.Sprintf("%s - Copy (%d)", base, count)
}
return base + " - Copy"
}

View File

@@ -0,0 +1,115 @@
package ruletypes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNextCloneAlertName(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{name: "PlainName_CopySuffixAppended", input: "High CPU", expected: "High CPU - Copy"},
{name: "CopySuffix_BumpedToTwo", input: "High CPU - Copy", expected: "High CPU - Copy (2)"},
{name: "NumberedSuffix_Incremented", input: "High CPU - Copy (2)", expected: "High CPU - Copy (3)"},
{name: "MultiDigitSuffix_Incremented", input: "svc - Copy (41)", expected: "svc - Copy (42)"},
{name: "ContainsCopyWord_NotTreatedAsSuffix", input: "Copy of things", expected: "Copy of things - Copy"},
{name: "RepeatedCopy_OnlyTrailingMarkerParsed", input: "Prod - Copy - Copy", expected: "Prod - Copy - Copy (2)"},
{name: "LongName_NotTruncated", input: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters", expected: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters - Copy"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, nextCloneAlertName(testCase.input))
})
}
}
func TestStorableRule_ToPostableRuleForCloning(t *testing.T) {
v1Stored := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"description": "watches cpu",
"ruleType": "threshold_rule",
"version": "v5",
"evalWindow": "10m",
"frequency": "2m",
"disabled": true,
"source": "https://signoz.example.com/alerts/new",
"labels": {"team": "infra", "severity": "warning"},
"annotations": {"summary": "cpu above {{$threshold}}"},
"preferredChannels": ["slack-infra"],
"condition": {
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu_usage", "timeAggregation": "avg", "spaceAggregation": "max"}]}}]
},
"selectedQueryName": "A",
"op": "1",
"target": 90,
"matchType": "1",
"alertOnAbsent": true,
"absentFor": 5
}
}`
v2Stored := `{
"alert": "cpu high - Copy",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "promql_rule",
"schemaVersion": "v2alpha1",
"version": "v5",
"disabled": false,
"labels": {"team": "infra"},
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "avg(cpu_usage)"}}],
"panelType": "graph",
"queryType": "promql"
},
"selectedQueryName": "A",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "90m", "frequency": "90s"}},
"notificationSettings": {"groupBy": ["service.name"], "renotify": {"enabled": true, "interval": "45m", "alertStates": ["firing"]}, "usePolicy": true}
}`
testCases := []struct {
name string
stored string
expectedName string
expectErr bool
}{
{name: "V1Rule_Cloned_NameSuffixedRestIntact", stored: v1Stored, expectedName: "cpu high - Copy"},
{name: "V2Alpha1Rule_Cloned_NameBumpedRestIntact", stored: v2Stored, expectedName: "cpu high - Copy (2)"},
{name: "InvalidJSON_Error", stored: `{"alert": `, expectErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &StorableRule{Data: testCase.stored}
cloned, err := rule.ToPostableRuleForCloning()
if testCase.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
expected := &PostableRule{}
require.NoError(t, json.Unmarshal([]byte(testCase.stored), expected))
expected.AlertName = testCase.expectedName
marshaled, err := json.Marshal(cloned)
require.NoError(t, err)
roundTripped := &PostableRule{}
require.NoError(t, json.Unmarshal(marshaled, roundTripped))
assert.Equal(t, expected, roundTripped)
})
}
}

View File

@@ -99,6 +99,7 @@ def pytest_addoption(parser: pytest.Parser):
default="delete",
help="sqlite mode",
)
# Keep the version defaults below in sync with pull-images in .github/workflows/e2eci.yaml.
parser.addoption(
"--postgres-version",
action="store",

View File

@@ -33,6 +33,8 @@ test.describe('LLM Observability — Model Pricing', () => {
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill(MODEL_NAME);
await page.getByTestId('drawer-pattern-input').fill(MODEL_NAME);
await page.getByTestId('drawer-pattern-add-btn').click();
await page.getByTestId('drawer-input-cost').fill(INPUT_COST);
await page.getByTestId('drawer-output-cost').fill(OUTPUT_COST);

View File

@@ -0,0 +1,161 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.alerts import delete_all_rules
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
CHANNEL_NAME = f"clone-rule-channel-{uuid.uuid4()}"
SOURCE_RULE = {
"alert": "clone me",
"description": "source rule for clone",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [CHANNEL_NAME]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "clone_rule_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical", "team": "payments"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
"notificationSettings": {
"groupBy": [],
"usePolicy": False,
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
},
"version": "v5",
"schemaVersion": "v2alpha1",
}
def test_clone_rule(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
delete_all_rules(signoz, token)
create_notification_channel({"name": CHANNEL_NAME, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]})
source_id = create_alert_rule(SOURCE_RULE)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
source = response.json()["data"]
# clone keeps everything but the id and name
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone = response.json()["data"]
assert clone["id"] != source_id
assert clone["alert"] == "clone me - Copy"
for field in ["description", "alertType", "ruleType", "disabled", "labels", "annotations", "condition", "evaluation", "notificationSettings"]:
assert clone[field] == source[field], field
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["alert"] == "clone me - Copy"
assert response.json()["data"]["state"] == "disabled"
assert response.json()["data"]["createdBy"] == USER_ADMIN_EMAIL
# cloning a copy bumps the counter
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
second_clone = response.json()["data"]
assert second_clone["alert"] == "clone me - Copy (2)"
assert second_clone["condition"] == source["condition"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
# a source whose channel was deleted since no longer passes create validation
stale_channel_name = f"clone-rule-stale-{uuid.uuid4()}"
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json={"name": stale_channel_name, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
stale_channel_id = response.json()["data"]["id"]
stale_rule = {
**SOURCE_RULE,
"alert": "stale channel",
"condition": {**SOURCE_RULE["condition"], "thresholds": {"kind": "basic", "spec": [{**SOURCE_RULE["condition"]["thresholds"]["spec"][0], "channels": [stale_channel_name]}]}},
"notificationSettings": {**SOURCE_RULE["notificationSettings"], "usePolicy": True},
}
stale_rule_id = create_alert_rule(stale_rule)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{stale_channel_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{stale_rule_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert stale_channel_name in response.json()["error"]["message"]
for rule_id in [clone["id"], second_clone["id"]]:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text