mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 22:50:48 +01:00
Compare commits
59 Commits
feat/trace
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c615baac03 | ||
|
|
32444ac89b | ||
|
|
10587de709 | ||
|
|
11a813b2b7 | ||
|
|
b6712d2c7f | ||
|
|
5b995b5bc1 | ||
|
|
9a8e795f31 | ||
|
|
3374261d1f | ||
|
|
019c9a6591 | ||
|
|
77d3a832ea | ||
|
|
db08d7792a | ||
|
|
b4e83ed7e3 | ||
|
|
0542d37adb | ||
|
|
5cfef312eb | ||
|
|
ca011493ea | ||
|
|
969d1c0840 | ||
|
|
ef71ee3510 | ||
|
|
498c89fb14 | ||
|
|
268c67d34b | ||
|
|
206349b75c | ||
|
|
11ae922ff7 | ||
|
|
3e087fe7bd | ||
|
|
9ec39f2494 | ||
|
|
ef67a47cf3 | ||
|
|
441f7e2927 | ||
|
|
4aff26f696 | ||
|
|
47a773245b | ||
|
|
375b3f8305 | ||
|
|
aaec7c89c5 | ||
|
|
57c193ec8f | ||
|
|
da556abac1 | ||
|
|
cc8440248b | ||
|
|
e84ddfb61e | ||
|
|
dc9fda2916 | ||
|
|
670677bae2 | ||
|
|
e4d66c5413 | ||
|
|
1371cb0beb | ||
|
|
140498cb11 | ||
|
|
ba01bc0418 | ||
|
|
566089d48e | ||
|
|
785c2e2195 | ||
|
|
86ade5973e | ||
|
|
5d2e6d12e4 | ||
|
|
6b13794728 | ||
|
|
ba3ca5645e | ||
|
|
3d22976c72 | ||
|
|
059801d80d | ||
|
|
c44926b5f8 | ||
|
|
5f8fa52e7c | ||
|
|
cf3662a7e5 | ||
|
|
18527e62c2 | ||
|
|
0e2adf3329 | ||
|
|
04db70bade | ||
|
|
54aaad2cc3 | ||
|
|
c407456be7 | ||
|
|
49a0f7552e | ||
|
|
75cfe0b2c7 | ||
|
|
db3b7f7c34 | ||
|
|
bb62c9c060 |
@@ -17,3 +17,4 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
|
||||
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
|
||||
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
|
||||
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
|
||||
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,3 +1,4 @@
|
||||
*.css linguist-detectable=false
|
||||
*.stories.mocks.tsx linguist-generated=true
|
||||
**/__story_mockdata__/** linguist-generated=true
|
||||
tests/e2e/drivers/** linguist-generated=true
|
||||
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
51
.github/workflows/e2eci.yaml
vendored
51
.github/workflows/e2eci.yaml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -60,6 +60,7 @@ jobs:
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- promapiconformance
|
||||
- promote
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -231,5 +231,7 @@ pyrightconfig.json
|
||||
|
||||
# dev
|
||||
.dev/
|
||||
.deepsec/
|
||||
.deepsec.zip
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1,5 +1,219 @@
|
||||
components:
|
||||
schemas:
|
||||
AiobservabilitytypesBlobPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesChatMessage:
|
||||
properties:
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesCompactionPart:
|
||||
properties:
|
||||
content:
|
||||
nullable: true
|
||||
type: string
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
AiobservabilitytypesFilePart:
|
||||
properties:
|
||||
file_id:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- file_id
|
||||
type: object
|
||||
AiobservabilitytypesGenericPart:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCall:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCallResponse:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesInputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
|
||||
type: array
|
||||
AiobservabilitytypesOutputMessage:
|
||||
properties:
|
||||
finish_reason:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesOutputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
|
||||
type: array
|
||||
AiobservabilitytypesPart:
|
||||
discriminator:
|
||||
mapping:
|
||||
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
file: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
text: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
uri: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
|
||||
type: object
|
||||
AiobservabilitytypesParts:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesPart'
|
||||
type: array
|
||||
AiobservabilitytypesReasoningPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallPart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
server_tool_call:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- server_tool_call
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
server_tool_call_response:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- server_tool_call_response
|
||||
type: object
|
||||
AiobservabilitytypesTextPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesToolCallRequestPart:
|
||||
properties:
|
||||
arguments: {}
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
AiobservabilitytypesToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
response:
|
||||
nullable: true
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- response
|
||||
type: object
|
||||
AiobservabilitytypesUriPart:
|
||||
properties:
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- uri
|
||||
type: object
|
||||
AlertmanagertypesChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3367,6 +3581,69 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
legend:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
limitBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
|
||||
name:
|
||||
type: string
|
||||
offset:
|
||||
type: integer
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
- traces
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/TelemetrytypesSource'
|
||||
stepInterval:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Step'
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
DashboardtypesAreaChartAppearance:
|
||||
properties:
|
||||
fillMode:
|
||||
@@ -4420,6 +4697,7 @@ components:
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
|
||||
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
@@ -4429,6 +4707,7 @@ components:
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
|
||||
@@ -4438,12 +4717,25 @@ components:
|
||||
DashboardtypesQueryPluginKind:
|
||||
enum:
|
||||
- signoz/BuilderQuery
|
||||
- signoz/AIBuilderQuery
|
||||
- signoz/CompositeQuery
|
||||
- signoz/Formula
|
||||
- signoz/PromQLQuery
|
||||
- signoz/ClickHouseSQL
|
||||
- signoz/TraceOperator
|
||||
type: string
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AIBuilderQuery
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -7756,6 +8048,8 @@ components:
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
type: string
|
||||
indexes:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesWrappedIndex'
|
||||
@@ -7764,6 +8058,12 @@ components:
|
||||
type: string
|
||||
promote:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
- context
|
||||
- path
|
||||
type: object
|
||||
PromotetypesWrappedIndex:
|
||||
properties:
|
||||
@@ -9878,6 +10178,19 @@ components:
|
||||
- totalErrorSpansCount
|
||||
- hasMissingSpans
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
prevCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10192,6 +10505,72 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formattedInput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
span_id:
|
||||
type: string
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
required:
|
||||
- span_id
|
||||
- trace_id
|
||||
- parent_span_id
|
||||
- name
|
||||
- kind_string
|
||||
- time_unix
|
||||
- duration_nano
|
||||
- has_error
|
||||
- status_code_string
|
||||
- status_message
|
||||
- resource
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formattedInput
|
||||
- formattedOutput
|
||||
- formatterWarnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
@@ -10271,6 +10650,14 @@ components:
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
formattedInput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
@@ -13364,110 +13751,6 @@ paths:
|
||||
tags:
|
||||
- llmpricingrules
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: ListPromotedAndIndexedPaths
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- logs
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoints promotes and indexes paths
|
||||
operationId: HandlePromoteAndIndexPaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Promote and index paths
|
||||
tags:
|
||||
- logs
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/org/preferences:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -13639,6 +13922,156 @@ paths:
|
||||
tags:
|
||||
- preferences
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the promoted paths of every JSON column, each
|
||||
annotated with its signal and context. The signal, context, promoted and indexes
|
||||
query parameters filter the listing.
|
||||
operationId: ListPromotedPaths
|
||||
parameters:
|
||||
- in: query
|
||||
name: signal
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: context
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: promoted
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
- in: query
|
||||
name: indexes
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- tokenizer:
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
summary: List promoted paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint promotes paths of JSON columns to their promoted
|
||||
columns. Each path names its promotion target with its signal and context,
|
||||
e.g. traces/attribute.
|
||||
operationId: PromotePaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
logs_body:
|
||||
summary: 'Logs body: promote a path and index it'
|
||||
value:
|
||||
- context: body
|
||||
indexes:
|
||||
- fieldDataType: string
|
||||
granularity: 1
|
||||
type: ngrambf_v1(4, 1024, 2, 0)
|
||||
path: user.name
|
||||
promote: true
|
||||
signal: logs
|
||||
traces_attribute:
|
||||
summary: 'Traces attribute: promote a path'
|
||||
value:
|
||||
- context: attribute
|
||||
path: http.method
|
||||
promote: true
|
||||
signal: traces
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
- tokenizer:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
summary: Promote paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -16009,6 +16442,106 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the gen_ai spans of a trace, model calls, tool calls and
|
||||
agent spans, in timestamp order. The messages a span carries are decoded into
|
||||
formattedInput and formattedOutput, with formatterWarnings holding what the
|
||||
converter could not resolve; a span without messages has both empty. Pass
|
||||
nextCursor as after or prevCursor as before to page, or spanId to open the
|
||||
page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
in: query
|
||||
name: limit
|
||||
schema:
|
||||
description: Page size, at most 100. 0 means 20.
|
||||
type: integer
|
||||
- description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: after
|
||||
schema:
|
||||
description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
type: string
|
||||
- description: The prevCursor of a page; returns the spans before it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: before
|
||||
schema:
|
||||
description: The prevCursor of a page; returns the spans before it. Set
|
||||
only one of after, before and spanId.
|
||||
type: string
|
||||
- description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
in: query
|
||||
name: spanId
|
||||
schema:
|
||||
description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"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:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
@@ -28061,7 +28594,9 @@ paths:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans including all spans if total
|
||||
spans are under a limit, a max count otherwise. Aggregations are dropped compared
|
||||
to v3
|
||||
to v3. A span carrying gen_ai input or output messages also has them decoded
|
||||
into formattedInput and formattedOutput, with formatterWarnings holding what
|
||||
the converter could not resolve.
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -20,6 +20,7 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [Span Mapper](spanmapper.md) - Provenance of the shipped span mapper definitions and what is left unmapped
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
62
docs/contributing/go/spanmapper.md
Normal file
62
docs/contributing/go/spanmapper.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Span Mapper
|
||||
|
||||
Where each source key in the shipped definitions under `pkg/modules/spanmapper/implspanmapper/fs/definitions/` comes from. A group runs on spans with an attribute key containing one of its condition substrings; sources are tried by priority and the first present wins; an existing target is never overwritten. Bump `version` on every change.
|
||||
|
||||
## gen_ai.llm
|
||||
|
||||
Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` from the response, so a failed call has no model key).
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `llm.model_name`, `llm.provider`, `llm.system`, `llm.token_count.prompt`, `llm.token_count.completion`, `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write`, `llm.token_count.completion_details.reasoning`, `llm.finish_reason`, `input.value`, `output.value`, `embedding.model_name`, `reranker.model_name`, `openinference.span.kind` | OpenInference |
|
||||
| `llm.request.model`, `llm.response.model`, `llm.vendor`, `llm.usage.prompt_tokens`, `llm.usage.completion_tokens`, `llm.response.finish_reason`, `llm.response.stop_reason` | OpenLLMetry before the `gen_ai` rename |
|
||||
| `gen_ai.system`, `gen_ai.usage.prompt_tokens`, `gen_ai.usage.completion_tokens`, `gen_ai.usage.cache_read_input_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.reasoning_tokens`, `gen_ai.prompt`, `gen_ai.completion`, `llm.request.type`, `traceloop.span.kind` | OpenLLMetry, LiteLLM, CrewAI, Mistral, LangSmith, OpenRouter gateway |
|
||||
| `gen_ai.usage.cache_write.input_tokens` | OTel semconv draft spelling, Codex |
|
||||
| `gen_ai.response.finish_reason`, `gen_ai.usage.prompt_token_details.cached_read_tokens`, `gen_ai.usage.completion_token_details.reasoning_tokens` | Bifrost gateway |
|
||||
| `gen_ai.usage.input_tokens.cached` | OpenRouter gateway |
|
||||
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
|
||||
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
|
||||
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
|
||||
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
|
||||
| `session.id` | OpenInference, Langfuse, Claude Code |
|
||||
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
|
||||
|
||||
## gen_ai.tool
|
||||
|
||||
Condition: `tool.name` (OpenInference), `ai.toolCall.` (Vercel, Mastra), `tool_call_args`, `tool_response` (Google ADK), `tool_use_id` (Claude Code, opentelemetry-hooks). Not `tool_name`: Codex records it on the model call span.
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `tool.name`, `tool.id`, `tool.description`, `input.value`, `output.value`, `openinference.span.kind` | OpenInference |
|
||||
| `ai.toolCall.name`, `ai.toolCall.id`, `ai.toolCall.args`, `ai.toolCall.result` | Vercel AI SDK, Mastra |
|
||||
| `traceloop.entity.input`, `traceloop.entity.output`, `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
|
||||
| `gcp.vertex.agent.tool_call_args`, `gcp.vertex.agent.tool_response` | Google ADK |
|
||||
| `tool_name` | Claude Code `claude_code.tool` spans |
|
||||
| `gen_ai.client.tool_name`, `gen_ai.client.tool_use_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
|
||||
## gen_ai.agent
|
||||
|
||||
Condition: `agent`.
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `agent.name`, `agent.id`, `agent.description`, `openinference.span.kind` | OpenInference; `agent.id` and `agent.description` seen in no capture |
|
||||
| `agent_name`, `final_result` | Pydantic AI |
|
||||
| `crewai.agent.role`, `crewai.agent.id` | CrewAI |
|
||||
| `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
|
||||
| `agent_id` | Claude Code |
|
||||
|
||||
## Coverage of documented integrations
|
||||
|
||||
Every integration under [signoz.io/docs/llm-observability](https://signoz.io/docs/llm-observability/) was checked by looking at the instrumentation its page installs and the keys that instrumentation writes.
|
||||
|
||||
- Most pages install OpenInference or OpenLLMetry, or an SDK that writes OTel GenAI semconv itself (OpenAI, OpenAI Agents, Pydantic AI, AutoGen, Microsoft Agent Framework, Semantic Kernel, Copilot Chat, LiteLLM, OpenRouter). Their keys are the rows above.
|
||||
- Three write their own keys and have their own rows: Claude Code and Claude Agent SDK, Cursor through opentelemetry-hooks, vLLM (response model only).
|
||||
- Codex cannot be priced by renaming: `model` sits on the sampling function spans, `gen_ai.usage.*` on the child `handle_responses` span, `conversation.id` on the `codex.sampling` phase span, never together on one span.
|
||||
|
||||
To check a new integration, capture a trace and look for a model, usage, tool or messages key that no row above lists.
|
||||
|
||||
## Left unmapped on purpose
|
||||
|
||||
LiteLLM `llm.openai.messages` and `llm.openai.choices` (debug echo of the parent span's messages, Python repr). Total tokens and vendor cost keys (no target; the pricing processor owns cost). `ai.response.msToFirstChunk` and `gen_ai.latency.time_to_first_token` (milliseconds and seconds under one target).
|
||||
@@ -86,6 +86,9 @@ tests/
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Drivers
|
||||
|
||||
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
|
||||
|
||||
```ts
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -297,6 +297,8 @@
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"signoz/no-msw-in-story-file": "error",
|
||||
// Bans msw imports in *.stories.tsx; handlers/mock data belong in the sibling .stories.mocks.tsx
|
||||
"signoz/no-direct-react-router-import": "error",
|
||||
// Steers call sites to the src/lib/router facade (allowlisted in overrides below)
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
@@ -338,6 +340,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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -580,14 +586,20 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
|
||||
// algorithms that read worse when split up.
|
||||
// The react-router allowlist: the facade itself, the route table, `useSafeNavigate`,
|
||||
// the history singleton and the test and story router mounts. Test files are
|
||||
// deliberately absent: they render at a route or use tests/router-mocks.
|
||||
"files": [
|
||||
"scripts/**"
|
||||
"src/lib/router/**",
|
||||
"src/AppRoutes/**",
|
||||
"src/hooks/useSafeNavigate.ts",
|
||||
"src/lib/history.ts",
|
||||
"src/tests/router.tsx",
|
||||
"src/storybook/providers/StorybookProviders.tsx",
|
||||
"src/storybook/renderAtRoute.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"sonarjs/cognitive-complexity": "off"
|
||||
"signoz/no-direct-react-router-import": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -43,6 +43,11 @@ const mockAliases = [
|
||||
find: /^@signozhq\/ui\/tooltip$/,
|
||||
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, a test asserts on the toast it fires.
|
||||
find: /^@signozhq\/ui\/sonner$/,
|
||||
replacement: `${srcPath}/storybook/mocks/sonner.mock.tsx`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -70,3 +70,49 @@
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Storybook has no microphone. Chromium's recognizer asks for one and for
|
||||
// Google's speech service, and the `not-allowed` or `network` it reports makes
|
||||
// the assistant hide its mic for the rest of the tab. This one starts
|
||||
// listening at once and hears only what a story passes to `hear`.
|
||||
(() => {
|
||||
class StorySpeechRecognition extends EventTarget {
|
||||
static listening = null;
|
||||
lang = 'en-US';
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
onstart = null;
|
||||
onresult = null;
|
||||
onerror = null;
|
||||
onend = null;
|
||||
|
||||
start() {
|
||||
StorySpeechRecognition.listening = this;
|
||||
setTimeout(() => this.onstart?.());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (StorySpeechRecognition.listening === this) {
|
||||
StorySpeechRecognition.listening = null;
|
||||
}
|
||||
setTimeout(() => this.onend?.());
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** An interim result: the words so far, still listening. */
|
||||
hear(transcript) {
|
||||
const result = Object.assign([{ transcript, confidence: 1 }], {
|
||||
isFinal: false,
|
||||
});
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}
|
||||
}
|
||||
|
||||
window.SpeechRecognition = StorySpeechRecognition;
|
||||
window.webkitSpeechRecognition = StorySpeechRecognition;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -122,7 +122,21 @@ const preview: Preview = {
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
[
|
||||
'List',
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
'Edit Variable',
|
||||
'Authz',
|
||||
],
|
||||
'Panel Editor',
|
||||
['Overview', 'Chart Options', 'Thresholds and Links'],
|
||||
'Public',
|
||||
],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
|
||||
@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
// The filter editor's ANTLR parser reports every syntax error through
|
||||
// `console.error` (`line 1:14 missing ...`), so each partial expression
|
||||
// typed into it logs one; the editor shows the same errors on screen.
|
||||
/^line \d+:\d+ /,
|
||||
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
|
||||
// already reports with its URL and the time its request left.
|
||||
/^Failed to load resource: the server responded with a status of /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push({
|
||||
at: Date.now(),
|
||||
// The browser's clock, when the request left: a busy runner receives
|
||||
// the outgoing story's responses after the next story has started.
|
||||
at: response.request().timing().startTime,
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
|
||||
@@ -41,6 +41,12 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
8. **Expose the selector.** A role is scoped to a resource by pasting its selector into the role editor, so every
|
||||
resource a role can be scoped to shows that selector with a copy control on its detail view or drawer. The selector
|
||||
is whatever the editor accepts for the resource (dashboard ID, service account ID, role name, ...), listed per
|
||||
resource in [permissions.config.ts](../src/container/RolesSettings/permissions.config.ts). It is already known from
|
||||
the row or URL, so it stays visible and copyable without `read`. Reference: the ID field in
|
||||
[ServiceAccountDrawer/OverviewTab.tsx](../src/components/ServiceAccountDrawer/OverviewTab.tsx).
|
||||
|
||||
## Page patterns
|
||||
|
||||
@@ -136,6 +142,8 @@ Use the existing components. Create a new one only if none fit.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
- [ ] Does the page expose the resource's selector with a copy control (rule 8)?
|
||||
- The expected format is the `selectorPlaceholder` for the resource in `permissions.config.ts`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -41,6 +41,20 @@ if (!HTMLElement.prototype.releasePointerCapture) {
|
||||
HTMLElement.prototype.releasePointerCapture = function (): void {};
|
||||
}
|
||||
|
||||
// jsdom has no PointerEvent; Base UI Switch constructs one on click.
|
||||
if (typeof window.PointerEvent === 'undefined') {
|
||||
class PointerEventMock extends MouseEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 0;
|
||||
this.pointerType = init.pointerType ?? '';
|
||||
}
|
||||
}
|
||||
(window as any).PointerEvent = PointerEventMock;
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
|
||||
@@ -49,12 +49,13 @@
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.6",
|
||||
"@signozhq/design-tokens": "2.2.1",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/ui": "0.1.0",
|
||||
"@signozhq/ui": "0.2.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ruleTester } from './rule-tester.mjs';
|
||||
|
||||
const FACADE = 'src/lib/router facade';
|
||||
const HISTORY = 'lib/history singleton';
|
||||
const MOCK = 'tests/router-mocks helpers';
|
||||
|
||||
await ruleTester({
|
||||
rule: 'no-direct-react-router-import',
|
||||
valid: [
|
||||
{
|
||||
name: 'facade import',
|
||||
code: "import { useAppNavigate } from 'lib/router/useAppNavigate';",
|
||||
},
|
||||
{
|
||||
name:
|
||||
'the history package itself is a version-bump concern, not a facade one',
|
||||
code: "import { createBrowserHistory } from 'history';",
|
||||
},
|
||||
{
|
||||
name: 'unrelated module whose name contains history',
|
||||
code: "import { useHistoryPanel } from 'container/HistoryPanel';",
|
||||
},
|
||||
{
|
||||
name: 'mocking a facade module',
|
||||
code: "jest.mock('lib/router/navigation');",
|
||||
},
|
||||
{
|
||||
name: 'jest call that does not name a module',
|
||||
code: 'jest.fn();',
|
||||
},
|
||||
{
|
||||
name: 'require of an unrelated module',
|
||||
code: "const x = require('lib/dashboardVariables');",
|
||||
},
|
||||
{
|
||||
name: 'export without a source',
|
||||
code: 'const a = 1;\nexport { a };',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'jest.mock of react-router-dom',
|
||||
code: "jest.mock('react-router-dom', () => ({}));",
|
||||
errors: [{ message: MOCK, line: 1, column: 11 }],
|
||||
},
|
||||
{
|
||||
name: 'jest.requireActual of react-router-dom',
|
||||
code: "const actual = jest.requireActual('react-router-dom');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'jest.mock of lib/history',
|
||||
code: "jest.mock('lib/history');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'jest.doMock of react-router-dom-v5-compat',
|
||||
code: "jest.doMock('react-router-dom-v5-compat');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom named import',
|
||||
code: "import { useHistory } from 'react-router-dom';",
|
||||
errors: [{ message: FACADE, line: 1, column: 28 }],
|
||||
},
|
||||
{
|
||||
name: 'react-router named import',
|
||||
code: "import { useLocation } from 'react-router';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom type-only import',
|
||||
code: "import type { RouteProps } from 'react-router-dom';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'react-router-dom-v5-compat',
|
||||
code: "import { useNavigate } from 'react-router-dom-v5-compat';",
|
||||
errors: [{ message: 'compat package is an implementation detail' }],
|
||||
},
|
||||
{
|
||||
name: 'lib/history default import',
|
||||
code: "import history from 'lib/history';",
|
||||
errors: [{ message: HISTORY }],
|
||||
},
|
||||
{
|
||||
name: 'lib/history through the @/ alias',
|
||||
code: "import history from '@/lib/history';",
|
||||
errors: [{ message: HISTORY }],
|
||||
},
|
||||
{
|
||||
name: 'jest.mock of lib/history through the @/ alias',
|
||||
code: "jest.mock('@/lib/history');",
|
||||
errors: [{ message: MOCK }],
|
||||
},
|
||||
{
|
||||
name: 'lib/history require',
|
||||
code: "const history = require('lib/history').default;",
|
||||
errors: [{ message: HISTORY }],
|
||||
},
|
||||
{
|
||||
name: 'dynamic import',
|
||||
code: "const mod = await import('react-router-dom');",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 're-export',
|
||||
code: "export { Link } from 'react-router-dom';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'export all',
|
||||
code: "export * from 'react-router-dom';",
|
||||
errors: [{ message: FACADE }],
|
||||
},
|
||||
{
|
||||
name: 'one report per import statement',
|
||||
code:
|
||||
"import { Link } from 'react-router-dom';\nimport history from 'lib/history';",
|
||||
errors: [
|
||||
{ message: FACADE, line: 1 },
|
||||
{ message: HISTORY, line: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
123
frontend/plugins/rules/no-direct-react-router-import.mjs
Normal file
123
frontend/plugins/rules/no-direct-react-router-import.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Rule: no-direct-react-router-import
|
||||
*
|
||||
* Every router concern goes through the `src/lib/router/*` facade, so a router
|
||||
* upgrade is a change to one directory instead of ~300 call sites. This rule
|
||||
* keeps new call sites, and test mocks, from reaching past it.
|
||||
*
|
||||
* `history` (the package) is deliberately not flagged: it is a transitive concern of
|
||||
* the version bump, not something the facade replaces.
|
||||
*
|
||||
* The allowlist (the facade itself, the route table, `useSafeNavigate`, the history
|
||||
* singleton and the test and story router mounts) is applied via overrides in
|
||||
* .oxlintrc.json, not here, so the file list stays visible next to the severity.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
const HISTORY_MODULE_SUFFIX = path.join('src', 'lib', 'history');
|
||||
|
||||
const JEST_MODULE_METHODS = new Set([
|
||||
'mock',
|
||||
'doMock',
|
||||
'unmock',
|
||||
'dontMock',
|
||||
'setMock',
|
||||
'requireActual',
|
||||
'requireMock',
|
||||
'createMockFromModule',
|
||||
]);
|
||||
|
||||
const MESSAGE_IDS = {
|
||||
'react-router': 'v5Router',
|
||||
'react-router-dom': 'v5Router',
|
||||
'react-router-dom-v5-compat': 'v6Compat',
|
||||
'lib/history': 'historySingleton',
|
||||
'@/lib/history': 'historySingleton',
|
||||
};
|
||||
|
||||
/** Resolves `./history` / `../history` so files inside src/lib count too. */
|
||||
function isHistoryModule(specifier, filename) {
|
||||
if (!specifier.startsWith('.') || !filename) {
|
||||
return false;
|
||||
}
|
||||
const resolved = path.resolve(path.dirname(filename), specifier);
|
||||
return (
|
||||
resolved.endsWith(HISTORY_MODULE_SUFFIX) ||
|
||||
resolved.endsWith(`${HISTORY_MODULE_SUFFIX}.ts`)
|
||||
);
|
||||
}
|
||||
|
||||
function messageIdFor(specifier, filename) {
|
||||
if (MESSAGE_IDS[specifier] !== undefined) {
|
||||
return MESSAGE_IDS[specifier];
|
||||
}
|
||||
return isHistoryModule(specifier, filename) ? 'historySingleton' : null;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow direct react-router / lib/history imports; import from src/lib/router instead',
|
||||
category: 'React Router migration',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
v5Router:
|
||||
'Do not import react-router-dom directly. Use the src/lib/router facade — useAppNavigate, useAppLocation, useAppParams, AppLink, Redirect, matchRoute — so a router upgrade stays contained.',
|
||||
v6Compat:
|
||||
'Do not import react-router-dom-v5-compat directly. Use the src/lib/router facade instead; the compat package is an implementation detail of the migration and disappears with it.',
|
||||
historySingleton:
|
||||
'Do not import the lib/history singleton. Use useAppNavigate() inside components, or the imperative helpers in src/lib/router/navigation.ts outside them — history loses basename handling once the router owns it.',
|
||||
mock:
|
||||
'Do not mock the router directly. Render at a route with tests/test-utils, or spy on the facade with the tests/router-mocks helpers.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const report = (sourceNode, overrideMessageId) => {
|
||||
if (
|
||||
sourceNode === null ||
|
||||
sourceNode === undefined ||
|
||||
typeof sourceNode.value !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const messageId = messageIdFor(sourceNode.value, context.filename);
|
||||
if (messageId !== null) {
|
||||
context.report({
|
||||
node: sourceNode,
|
||||
messageId: overrideMessageId ?? messageId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ImportDeclaration: (node) => report(node.source),
|
||||
ImportExpression: (node) => report(node.source),
|
||||
ExportAllDeclaration: (node) => report(node.source),
|
||||
ExportNamedDeclaration: (node) => report(node.source),
|
||||
CallExpression(node) {
|
||||
const { callee } = node;
|
||||
if (
|
||||
(callee.type === 'Identifier' && callee.name === 'require') ||
|
||||
callee.type === 'Import'
|
||||
) {
|
||||
report(node.arguments[0]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'jest' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
JEST_MODULE_METHODS.has(callee.property.name)
|
||||
) {
|
||||
report(node.arguments[0], 'mock');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root
|
||||
import noConditionalTextNodesWithSiblings from './rules/no-conditional-text-nodes-with-siblings.mjs';
|
||||
import noReturnTextNodes from './rules/no-return-text-nodes.mjs';
|
||||
import noMswInStoryFile from './rules/no-msw-in-story-file.mjs';
|
||||
import noDirectReactRouterImport from './rules/no-direct-react-router-import.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -33,5 +34,6 @@ export default {
|
||||
'no-conditional-text-nodes-with-siblings': noConditionalTextNodesWithSiblings,
|
||||
'no-return-text-nodes': noReturnTextNodes,
|
||||
'no-msw-in-story-file': noMswInStoryFile,
|
||||
'no-direct-react-router-import': noDirectReactRouterImport,
|
||||
},
|
||||
};
|
||||
|
||||
441
frontend/pnpm-lock.yaml
generated
441
frontend/pnpm-lock.yaml
generated
@@ -62,6 +62,9 @@ importers:
|
||||
'@grafana/data':
|
||||
specifier: ^11.6.15
|
||||
version: 11.6.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -72,14 +75,14 @@ importers:
|
||||
specifier: 5.3.0
|
||||
version: 5.3.0
|
||||
'@signozhq/design-tokens':
|
||||
specifier: 2.1.6
|
||||
version: 2.1.6
|
||||
specifier: 2.2.1
|
||||
version: 2.2.1
|
||||
'@signozhq/icons':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
'@signozhq/ui':
|
||||
specifier: 0.1.0
|
||||
version: 0.1.0(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: 8.21.3
|
||||
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -1284,6 +1287,33 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.7.0':
|
||||
resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@date-fns/tz': ^1.2.0
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
date-fns: ^4.0.0
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@date-fns/tz':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
date-fns:
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.3.2':
|
||||
resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
@@ -1726,20 +1756,20 @@ packages:
|
||||
resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@floating-ui/core@1.5.2':
|
||||
resolution: {integrity: sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==}
|
||||
'@floating-ui/core@1.8.0':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
'@floating-ui/dom@1.5.3':
|
||||
resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==}
|
||||
'@floating-ui/dom@1.8.0':
|
||||
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
|
||||
|
||||
'@floating-ui/react-dom@2.0.4':
|
||||
resolution: {integrity: sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==}
|
||||
'@floating-ui/react-dom@2.1.9':
|
||||
resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.1.6':
|
||||
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
|
||||
@@ -2920,19 +2950,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.11':
|
||||
resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.15':
|
||||
resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
|
||||
peerDependencies:
|
||||
@@ -2946,15 +2963,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.5':
|
||||
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
|
||||
peerDependencies:
|
||||
@@ -3008,19 +3016,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.24':
|
||||
resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.6':
|
||||
resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==}
|
||||
peerDependencies:
|
||||
@@ -3048,15 +3043,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.4':
|
||||
resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
|
||||
peerDependencies:
|
||||
@@ -3066,19 +3052,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menu@2.1.24':
|
||||
resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.23':
|
||||
resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==}
|
||||
peerDependencies:
|
||||
@@ -3144,19 +3117,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.4':
|
||||
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-progress@1.1.16':
|
||||
resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==}
|
||||
peerDependencies:
|
||||
@@ -3222,15 +3182,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.4':
|
||||
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.3.3':
|
||||
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
|
||||
peerDependencies:
|
||||
@@ -3240,19 +3191,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-switch@1.3.7':
|
||||
resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.21':
|
||||
resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==}
|
||||
peerDependencies:
|
||||
@@ -3266,45 +3204,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.19':
|
||||
resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-toggle@1.1.18':
|
||||
resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.16':
|
||||
resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.4':
|
||||
resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==}
|
||||
peerDependencies:
|
||||
@@ -3341,15 +3240,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.4':
|
||||
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
|
||||
peerDependencies:
|
||||
@@ -3721,14 +3611,14 @@ packages:
|
||||
'@sideway/pinpoint@2.0.0':
|
||||
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
|
||||
|
||||
'@signozhq/design-tokens@2.1.6':
|
||||
resolution: {integrity: sha512-n0cH4TRS1Xj7BR38HGiwIBDekIOAnDgl2JyYmAyUvws0L1nlE0txSHO5NiOBJ22Dm8uMva0v5/rOHxpKsGQbsw==}
|
||||
'@signozhq/design-tokens@2.2.1':
|
||||
resolution: {integrity: sha512-ulf6lhWUP6qxqsyemQySUMfOIKRvefvZ5rBRv+wlhOpPfYPk3uatPmPkKEhAjISpX8f9g1Gg8wgRZxUCysf20Q==}
|
||||
|
||||
'@signozhq/icons@0.4.0':
|
||||
resolution: {integrity: sha512-QctNvNPlQlsefFdIVmSy/P/7a1IcQ3IryKP0KPMqtoqew3q89FGUmFa5IfEJ7Z3CHhtG1zaXwBKqP72QkLyA3g==}
|
||||
|
||||
'@signozhq/ui@0.1.0':
|
||||
resolution: {integrity: sha512-FjqmQhz+WtpGl29gPGZey5YwNe4p++TCyX4jaGUhAgvlBGRmxmAKW0lFRrSOYTPbgFL5UaDCW0wR62aiGHZ+FA==}
|
||||
'@signozhq/ui@0.2.0':
|
||||
resolution: {integrity: sha512-j20ldiOkR150ON0BCDhlq6fXGoPN++7w6e4t2HtM2XMM4buJO7aHzPzNKvDRZ2UUf/LGPULPAW8w6sg6gUVHyw==}
|
||||
peerDependencies:
|
||||
'@signozhq/icons': 0.3.0
|
||||
react: ^18.2.0
|
||||
@@ -8520,6 +8410,11 @@ packages:
|
||||
react: ^16.8.5 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
|
||||
|
||||
react-compiler-runtime@1.0.0:
|
||||
resolution: {integrity: sha512-rRfjYv66HlG8896yPUDONgKzG5BxZD1nV9U6rkm+7VCuvQc903C4MjcoZR4zPw53IKSOX9wMQVpA1IAbRtzQ7w==}
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental
|
||||
|
||||
react-day-picker@9.9.0:
|
||||
resolution: {integrity: sha512-NtkJbuX6cl/VaGNb3sVVhmMA6LSMnL5G3xNL+61IyoZj0mUZFWTg4hmj7PHjIQ8MXN9dHWhUHFoJWG6y60DKSg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -8907,6 +8802,9 @@ packages:
|
||||
requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
||||
reselect@5.3.0:
|
||||
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
|
||||
|
||||
reserved-words@0.1.2:
|
||||
resolution: {integrity: sha512-0S5SrIUJ9LfpbVl4Yzij6VipUdafHrOTzvmfazSw/jeZrZtQK303OPZW+obtkaw7jQlTQppy0UvZWm9872PbRw==}
|
||||
|
||||
@@ -11154,6 +11052,31 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@base-ui/react@1.7.0(@date-fns/tz@1.4.1)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@base-ui/utils': 0.3.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@floating-ui/utils': 0.2.12
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
use-sync-external-store: 1.6.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
'@types/react': 18.0.26
|
||||
date-fns: 4.1.0
|
||||
|
||||
'@base-ui/utils@0.3.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@floating-ui/utils': 0.2.12
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
reselect: 5.3.0
|
||||
use-sync-external-store: 1.6.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@braintree/sanitize-url@7.0.1': {}
|
||||
@@ -11598,22 +11521,22 @@ snapshots:
|
||||
'@eslint/core': 1.2.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@floating-ui/core@1.5.2':
|
||||
'@floating-ui/core@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.1.6
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/dom@1.5.3':
|
||||
'@floating-ui/dom@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.5.2
|
||||
'@floating-ui/utils': 0.1.6
|
||||
'@floating-ui/core': 1.8.0
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/react-dom@2.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
'@floating-ui/react-dom@2.1.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.5.3
|
||||
'@floating-ui/dom': 1.8.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
'@floating-ui/utils@0.1.6': {}
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@gerrit0/mini-shiki@3.23.0':
|
||||
dependencies:
|
||||
@@ -12763,21 +12686,6 @@ snapshots:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.11(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-presence': 1.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-use-size': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-collection@1.1.15(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
@@ -12790,12 +12698,6 @@ snapshots:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.5(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -12850,21 +12752,6 @@ snapshots:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-menu': 2.1.24(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.6(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -12886,13 +12773,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-id@1.1.4(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
@@ -12900,32 +12780,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-menu@2.1.24(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-collection': 1.1.15(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-direction': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-focus-guards': 1.1.6(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-popper': 1.3.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-portal': 1.1.17(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-presence': 1.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
aria-hidden: 1.2.6
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-remove-scroll: 2.7.2(@types/react@18.0.26)(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-popover@1.1.23(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
@@ -12951,7 +12805,7 @@ snapshots:
|
||||
|
||||
'@radix-ui/react-popper@1.3.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-arrow': 1.1.15(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
@@ -12995,15 +12849,6 @@ snapshots:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.4(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-progress@1.1.16(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
@@ -13099,13 +12944,6 @@ snapshots:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-slot@1.2.4(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-slot@1.3.3(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
@@ -13113,20 +12951,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-switch@1.3.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-use-size': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-tabs@1.1.21(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
@@ -13143,53 +12967,6 @@ snapshots:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.19(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-direction': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-toggle@1.1.18(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.0.26)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.16(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-popper': 1.3.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-portal': 1.1.17(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-presence': 1.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
'@types/react-dom': 18.0.10
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.4(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -13218,12 +12995,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
optionalDependencies:
|
||||
'@types/react': 18.0.26
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.4(@types/react@18.0.26)(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
@@ -13272,14 +13043,14 @@ snapshots:
|
||||
|
||||
'@rc-component/context@1.4.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.29.2
|
||||
rc-util: 5.38.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
'@rc-component/mini-decimal@1.0.1':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
'@rc-component/mutate-observer@1.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
@@ -13291,7 +13062,7 @@ snapshots:
|
||||
|
||||
'@rc-component/portal@1.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.29.2
|
||||
classnames: 2.3.2
|
||||
rc-util: 5.38.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
@@ -13559,16 +13330,15 @@ snapshots:
|
||||
|
||||
'@sideway/pinpoint@2.0.0': {}
|
||||
|
||||
'@signozhq/design-tokens@2.1.6': {}
|
||||
'@signozhq/design-tokens@2.2.1': {}
|
||||
|
||||
'@signozhq/icons@0.4.0': {}
|
||||
|
||||
'@signozhq/ui@0.1.0(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)':
|
||||
'@signozhq/ui@0.2.0(@date-fns/tz@1.4.1)(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@base-ui/react': 1.7.0(@date-fns/tz@1.4.1)(@types/react@18.0.26)(date-fns@4.1.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@chenglou/pretext': 0.0.8
|
||||
'@radix-ui/react-checkbox': 1.3.11(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-dialog': 1.1.23(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-icons': 1.3.2(react@18.2.0)
|
||||
'@radix-ui/react-popover': 1.1.23(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-progress': 1.1.16(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -13576,11 +13346,7 @@ snapshots:
|
||||
'@radix-ui/react-select': 2.3.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-slider': 1.4.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-switch': 1.3.7(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-tabs': 1.1.21(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-toggle': 1.1.18(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-tooltip': 1.2.16(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@signozhq/icons': 0.4.0
|
||||
'@tanstack/react-table': 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@tanstack/react-virtual': 3.14.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -13594,16 +13360,19 @@ snapshots:
|
||||
next-themes: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
nuqs: 2.9.6(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.6(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-compiler-runtime: 1.0.0(react@18.2.0)
|
||||
react-day-picker: 9.9.0(react@18.2.0)
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-resizable-panels: 4.12.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
sonner: 2.0.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@date-fns/tz'
|
||||
- '@emotion/is-prop-valid'
|
||||
- '@remix-run/react'
|
||||
- '@tanstack/react-router'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- date-fns
|
||||
- next
|
||||
- react-router
|
||||
- react-router-dom
|
||||
@@ -15129,10 +14898,10 @@ snapshots:
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-dialog': 1.1.23(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.1.4(@types/react@18.0.26)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
@@ -19277,6 +19046,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native
|
||||
|
||||
react-compiler-runtime@1.0.0(react@18.2.0):
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
react-day-picker@9.9.0(react@18.2.0):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
@@ -19762,6 +19535,8 @@ snapshots:
|
||||
|
||||
requires-port@1.0.0: {}
|
||||
|
||||
reselect@5.3.0: {}
|
||||
|
||||
reserved-words@0.1.2: {}
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
@@ -19867,7 +19642,7 @@ snapshots:
|
||||
|
||||
rtl-css-js@1.16.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.29.2
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
@@ -20679,7 +20454,7 @@ snapshots:
|
||||
|
||||
unload@2.2.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.2
|
||||
'@babel/runtime': 7.29.2
|
||||
detect-node: 2.1.0
|
||||
|
||||
unplugin@2.3.11:
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -41,17 +41,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
message: data?.error,
|
||||
};
|
||||
}
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
if (request) {
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
// client never received a response, or request never left
|
||||
console.error('client never received a response, or request never left');
|
||||
|
||||
|
||||
22
frontend/src/api/__tests__/ErrorResponseHandler.test.ts
Normal file
22
frontend/src/api/__tests__/ErrorResponseHandler.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError, CanceledError } from 'axios';
|
||||
|
||||
describe('ErrorResponseHandler', () => {
|
||||
// axios aborts a request whose signal fired before it was sent without ever
|
||||
// creating one, so the error carries neither `response` nor `request`.
|
||||
it('treats a request cancelled before it was sent as a cancellation', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
});
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -4,23 +4,15 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
HandleExportRawDataPOSTParams,
|
||||
ListPromotedAndIndexedPaths200,
|
||||
PromotetypesPromotePathDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -28,26 +20,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints allows complex query exporting raw data for traces and logs
|
||||
* @summary Export raw data
|
||||
@@ -149,175 +121,3 @@ export const useHandleExportRawDataPOST = <
|
||||
> => {
|
||||
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoints promotes and indexes paths
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
|
||||
url: `/api/v1/logs/promote_paths`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPromotedAndIndexedPathsQueryKey = () => {
|
||||
return [`/api/v1/logs/promote_paths`] as const;
|
||||
};
|
||||
|
||||
export const getListPromotedAndIndexedPathsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
|
||||
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
|
||||
>;
|
||||
export type ListPromotedAndIndexedPathsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
|
||||
export function useListPromotedAndIndexedPaths<
|
||||
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const invalidateListPromotedAndIndexedPaths = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints promotes and indexes paths
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const handlePromoteAndIndexPaths = (
|
||||
promotetypesPromotePathDTONull?: BodyType<
|
||||
PromotetypesPromotePathDTO[] | null
|
||||
> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/logs/promote_paths`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPromotePathDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getHandlePromoteAndIndexPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['handlePromoteAndIndexPaths'];
|
||||
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 handlePromoteAndIndexPaths>>,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return handlePromoteAndIndexPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
|
||||
>;
|
||||
export type HandlePromoteAndIndexPathsMutationBody =
|
||||
| BodyType<PromotetypesPromotePathDTO[] | null>
|
||||
| undefined;
|
||||
export type HandlePromoteAndIndexPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const useHandlePromoteAndIndexPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
232
frontend/src/api/generated/services/promote/index.ts
Normal file
232
frontend/src/api/generated/services/promote/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
export const listPromotedPaths = (
|
||||
params?: ListPromotedPathsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListPromotedPaths200>({
|
||||
url: `/api/v1/promoted_paths`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPromotedPathsQueryKey = (
|
||||
params?: ListPromotedPathsParams,
|
||||
) => {
|
||||
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListPromotedPathsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>
|
||||
> = ({ signal }) => listPromotedPaths(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListPromotedPathsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>
|
||||
>;
|
||||
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
|
||||
export function useListPromotedPaths<
|
||||
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListPromotedPathsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
export const invalidateListPromotedPaths = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListPromotedPathsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
|
||||
* @summary Promote paths
|
||||
*/
|
||||
export const promotePaths = (
|
||||
promotetypesPromotePathDTONull?: BodyType<
|
||||
PromotetypesPromotePathDTO[] | null
|
||||
> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/promoted_paths`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPromotePathDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPromotePathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['promotePaths'];
|
||||
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 promotePaths>>,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return promotePaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PromotePathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof promotePaths>>
|
||||
>;
|
||||
export type PromotePathsMutationBody =
|
||||
| BodyType<PromotetypesPromotePathDTO[] | null>
|
||||
| undefined;
|
||||
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote paths
|
||||
*/
|
||||
export const usePromotePaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@ import type {
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -257,6 +260,121 @@ export const invalidateGetTraceSummary = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
@@ -357,7 +475,7 @@ export const useGetFlamegraph = <
|
||||
return useMutation(getGetFlamegraphMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3. A span carrying gen_ai input or output messages also has them decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve.
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
|
||||
import deleteSession from './v2/sessions/delete';
|
||||
|
||||
@@ -21,5 +21,5 @@ export const Logout = async (): Promise<void> => {
|
||||
deleteLocalStorageKey(LOCALSTORAGE.CHAT_SUPPORT);
|
||||
deleteLocalStorageKey(LOCALSTORAGE.USER_ID);
|
||||
window.dispatchEvent(new CustomEvent('LOGOUT'));
|
||||
history.push(ROUTES.LOGIN);
|
||||
navigate(ROUTES.LOGIN);
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 60 60"><linearGradient id="a" x1="15.959" x2="44.307" y1="-13.014" y2="15.333" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".097" style="stop-color:#0095d5"/><stop offset=".301" style="stop-color:#238ad9"/><stop offset=".621" style="stop-color:#557bde"/><stop offset=".864" style="stop-color:#7472e2"/><stop offset="1" style="stop-color:#806ee3"/></linearGradient><path d="m0 60 30.1-30.1L60 60z" style="fill:url(#a)"/><linearGradient id="b" x1="4.209" x2="20.673" y1="48.941" y2="65.405" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".118" style="stop-color:#0095d5"/><stop offset=".418" style="stop-color:#3c83dc"/><stop offset=".696" style="stop-color:#6d74e1"/><stop offset=".833" style="stop-color:#806ee3"/></linearGradient><path d="M0 0h30.1L0 32.5z" style="fill:url(#b)"/><linearGradient id="c" x1="-10.102" x2="45.731" y1="5.836" y2="61.669" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".107" style="stop-color:#c757bc"/><stop offset=".214" style="stop-color:#d0609a"/><stop offset=".425" style="stop-color:#e1725c"/><stop offset=".605" style="stop-color:#ee7e2f"/><stop offset=".743" style="stop-color:#f58613"/><stop offset=".823" style="stop-color:#f88909"/></linearGradient><path d="M30.1 0 0 31.7V60l30.1-30.1L60 0z" style="fill:url(#c)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
1
frontend/src/assets/Logos/bun.svg
Normal file
1
frontend/src/assets/Logos/bun.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#9CA3AF" d="M12 22.596c6.628 0 12-4.338 12-9.688 0-3.318-2.057-6.248-5.219-7.986-1.286-.715-2.297-1.357-3.139-1.89C14.058 2.025 13.08 1.404 12 1.404c-1.097 0-2.334.785-3.966 1.821a49.92 49.92 0 0 1-2.816 1.697C2.057 6.66 0 9.59 0 12.908c0 5.35 5.372 9.687 12 9.687v.001ZM10.599 4.715c.334-.759.503-1.58.498-2.409 0-.145.202-.187.23-.029.658 2.783-.902 4.162-2.057 4.624-.124.048-.199-.121-.103-.209a5.763 5.763 0 0 0 1.432-1.977Zm2.058-.102a5.82 5.82 0 0 0-.782-2.306v-.016c-.069-.123.086-.263.185-.172 1.962 2.111 1.307 4.067.556 5.051-.082.103-.23-.003-.189-.126a5.85 5.85 0 0 0 .23-2.431Zm1.776-.561a5.727 5.727 0 0 0-1.612-1.806v-.014c-.112-.085-.024-.274.114-.218 2.595 1.087 2.774 3.18 2.459 4.407a.116.116 0 0 1-.049.071.11.11 0 0 1-.153-.026.122.122 0 0 1-.022-.083 5.891 5.891 0 0 0-.737-2.331Zm-5.087.561c-.617.546-1.282.76-2.063 1-.117 0-.195-.078-.156-.181 1.752-.909 2.376-1.649 2.999-2.778 0 0 .155-.118.188.085 0 .304-.349 1.329-.968 1.874Zm4.945 11.237a2.957 2.957 0 0 1-.937 1.553c-.346.346-.8.565-1.286.62a2.178 2.178 0 0 1-1.327-.62 2.955 2.955 0 0 1-.925-1.553.244.244 0 0 1 .064-.198.234.234 0 0 1 .193-.069h3.965a.226.226 0 0 1 .19.07c.05.053.073.125.063.197Zm-5.458-2.176a1.862 1.862 0 0 1-2.384-.245 1.98 1.98 0 0 1-.233-2.447c.207-.319.503-.566.848-.713a1.84 1.84 0 0 1 1.092-.11c.366.075.703.261.967.531a1.98 1.98 0 0 1 .408 2.114 1.931 1.931 0 0 1-.698.869v.001Zm8.495.005a1.86 1.86 0 0 1-2.381-.253 1.964 1.964 0 0 1-.547-1.366c0-.384.11-.76.32-1.079.207-.319.503-.567.849-.713a1.844 1.844 0 0 1 1.093-.108c.367.076.704.262.968.534a1.98 1.98 0 0 1 .4 2.117 1.932 1.932 0 0 1-.702.868Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/Logos/microsoft.svg
Normal file
1
frontend/src/assets/Logos/microsoft.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#F25022" d="M1 1h10.5v10.5H1z"/><path fill="#7FBA00" d="M12.5 1H23v10.5H12.5z"/><path fill="#00A4EF" d="M1 12.5h10.5V23H1z"/><path fill="#FFB900" d="M12.5 12.5H23V23H12.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -17,7 +17,10 @@ function ModalFixture(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="open-add-credit-card"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="open-add-credit-card"
|
||||
onClick={(): void => setOpen(true)}
|
||||
>
|
||||
Contact support
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
:global(.ant-breadcrumb-separator) {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
[data-slot='button'] {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
.item {
|
||||
--button-padding: 0;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.itemLast {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--button-secondary-label);
|
||||
font-size: var(--button-font-size, var(--periscope-font-size-base));
|
||||
line-height: var(--button-size-line-height, 16px);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ function BreadcrumbItem({
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
className={styles.item}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
if (!('route' in props) || !props.route) {
|
||||
return;
|
||||
|
||||
@@ -34,21 +34,23 @@ function ErrorEmptyState({
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={onContactSupport}
|
||||
data-testid="error-contact-support-button"
|
||||
testId="error-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="error-refresh-button"
|
||||
testId="error-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
.labelColumn {
|
||||
// Badges fill their wrapper; the wrapper, as the flex item, owns the cap.
|
||||
--badge-max-width: 100%;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
// Kept mounted so its width can still be measured.
|
||||
.overflowed {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
// The only visible label truncates instead of pushing `+N` out of view.
|
||||
.labelColumn > .shrinkable {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overflowTrigger {
|
||||
@@ -22,11 +34,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflowBadge {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
import LabelColumn from './LabelColumn';
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
const BADGE_WIDTH = 60;
|
||||
|
||||
let resizeCallbacks: ResizeObserverCallback[] = [];
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
resizeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
observe = jest.fn();
|
||||
@@ -15,15 +16,23 @@ class MockResizeObserver {
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
function triggerResize(width: number): void {
|
||||
if (resizeCallback) {
|
||||
act(() => {
|
||||
resizeCallback?.(
|
||||
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
// jsdom has no layout: every badge measures BADGE_WIDTH, the column `containerWidth`.
|
||||
function mockLayout(containerWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockImplementation(function getClientWidth(this: HTMLElement) {
|
||||
return this.dataset.testid === 'label-column' ? containerWidth : 0;
|
||||
});
|
||||
}
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: BADGE_WIDTH } as DOMRect);
|
||||
}
|
||||
|
||||
function triggerResize(containerWidth: number): void {
|
||||
mockLayout(containerWidth);
|
||||
act(() => {
|
||||
resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver));
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -31,13 +40,14 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeCallback = null;
|
||||
resizeCallbacks = [];
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
): ReturnType<typeof render> {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
return render(ui);
|
||||
}
|
||||
|
||||
describe('LabelColumn', () => {
|
||||
@@ -54,13 +64,10 @@ describe('LabelColumn', () => {
|
||||
it('should truncate labels and show +N badge when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
// 3 badges (180px) + overflow reserve (40px)
|
||||
mockLayout(220);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container that fits ~3 badges
|
||||
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
|
||||
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
|
||||
triggerResize(220);
|
||||
|
||||
// First 3 visible
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
@@ -93,19 +100,37 @@ describe('LabelColumn', () => {
|
||||
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
|
||||
});
|
||||
|
||||
it('should show overflow badge with remaining count when container is narrow', () => {
|
||||
it('should update the overflow count when the container resizes', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
mockLayout(220);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
|
||||
|
||||
// 2 badges (120px) + overflow reserve (40px)
|
||||
triggerResize(160);
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+4');
|
||||
|
||||
triggerResize(1000);
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should skip the overflow reserve when every label fits', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
mockLayout(labels.length * BADGE_WIDTH);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container to trigger overflow (shows 3 labels)
|
||||
// 220px fits first 3 badges before overflow
|
||||
triggerResize(220);
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Overflow badge shows +3 (remaining labels)
|
||||
const overflowBadge = screen.getByTestId('label-overflow-badge');
|
||||
expect(overflowBadge).toBeInTheDocument();
|
||||
expect(overflowBadge).toHaveTextContent('+3');
|
||||
it('should keep one label visible when even that one does not fit', () => {
|
||||
const labels = ['env', 'service'];
|
||||
|
||||
mockLayout(50);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+1');
|
||||
});
|
||||
|
||||
it('should render empty when no labels provided', () => {
|
||||
@@ -126,11 +151,9 @@ describe('LabelColumn', () => {
|
||||
it('should show all labels when container is wide enough', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
mockLayout(1000);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate wide container
|
||||
triggerResize(1000);
|
||||
|
||||
// All labels visible
|
||||
labels.forEach((label) => {
|
||||
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import LabelTag from './LabelTag';
|
||||
|
||||
import styles from './LabelColumn.module.scss';
|
||||
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
|
||||
import { getVisibleCount } from './utils';
|
||||
|
||||
export interface LabelColumnProps {
|
||||
labels: string[];
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
color?: BadgeColorType;
|
||||
value?: { [key: string]: string };
|
||||
}
|
||||
|
||||
@@ -39,60 +23,37 @@ function LabelColumn({
|
||||
color = 'primary',
|
||||
}: LabelColumnProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
|
||||
const [visibleCount, setVisibleCount] = useState(labels.length);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const calculateMaxVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const label of labels) {
|
||||
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[labels, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
|
||||
const measure = (): void => {
|
||||
const available = container.clientWidth;
|
||||
if (available <= 0) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const widths = Array.from(container.children)
|
||||
.slice(0, labels.length)
|
||||
.map((item) => item.getBoundingClientRect().width);
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 0;
|
||||
|
||||
setVisibleCount(getVisibleCount(widths, available, gap));
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(container);
|
||||
|
||||
if (container.clientWidth > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
|
||||
}
|
||||
measure();
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateMaxVisible]);
|
||||
}, [labels, value]);
|
||||
|
||||
const needsOverflow = labels.length > maxVisibleCount;
|
||||
const visibleLabels = needsOverflow
|
||||
? labels.slice(0, maxVisibleCount)
|
||||
: labels;
|
||||
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
|
||||
const remainingLabels = labels.slice(visibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -100,24 +61,23 @@ function LabelColumn({
|
||||
className={styles.labelColumn}
|
||||
data-testid="label-column"
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
|
||||
{labels.map((label, index) => (
|
||||
<LabelTag
|
||||
key={label}
|
||||
label={label}
|
||||
color={color}
|
||||
value={value?.[label]}
|
||||
className={cx({
|
||||
[styles.overflowed]: index >= visibleCount,
|
||||
[styles.shrinkable]: index === 0 && visibleCount === 1,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{remainingLabels.length > 0 && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.overflowBadge}
|
||||
variant="outline"
|
||||
data-testid="label-overflow-badge"
|
||||
>
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end">
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
align="end"
|
||||
title={
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>
|
||||
{remainingLabels
|
||||
@@ -140,8 +100,14 @@ function LabelColumn({
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Badge color={color} variant="outlined" testId="label-overflow-badge">
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LabelTag.module.scss';
|
||||
|
||||
export interface LabelTagProps {
|
||||
label: string;
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
color?: BadgeColorType;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
function LabelTag({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
className,
|
||||
}: LabelTagProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
const searchFormat = value ? `${label} ${value}` : label;
|
||||
@@ -41,20 +30,8 @@ function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>{displayText}</span>
|
||||
<button
|
||||
@@ -66,8 +43,19 @@ function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
}
|
||||
>
|
||||
<span className={className}>
|
||||
<Badge
|
||||
textTransform="none"
|
||||
color={color ?? 'secondary'}
|
||||
variant="outlined"
|
||||
testId={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import LabelColumn from '../LabelColumn';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Label Column',
|
||||
component: LabelColumn,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 180 })],
|
||||
args: {
|
||||
labels: ['severity', 'team', 'service', 'environment'],
|
||||
value: {
|
||||
severity: 'critical',
|
||||
team: 'payments',
|
||||
service: 'checkout',
|
||||
environment: 'production',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof LabelColumn>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Labels that fit the column; the rest collapse into a `+N` chip. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Copying one label from its tooltip. */
|
||||
export const LabelCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.hover(
|
||||
await within(canvasElement).findByTestId('label-tag-severity'),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy to clipboard' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Copying the labels that overflow the column from the `+N` chip's tooltip. */
|
||||
export const OverflowLabelsCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.hover(
|
||||
await within(canvasElement).findByTestId('label-overflow-badge'),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy to clipboard' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,14 +1,33 @@
|
||||
export const BADGE_GAP = 4;
|
||||
export const OVERFLOW_BADGE_WIDTH = 40;
|
||||
|
||||
export const BADGE_MAX_WIDTH = 180;
|
||||
export const BADGE_PADDING = 16;
|
||||
export const CHAR_WIDTH = 7;
|
||||
|
||||
export function estimateBadgeWidth(label: string, value?: string): number {
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
return Math.min(
|
||||
displayText.length * CHAR_WIDTH + BADGE_PADDING,
|
||||
BADGE_MAX_WIDTH,
|
||||
/**
|
||||
* How many leading labels fit in `available` px, reserving room for the `+N` badge
|
||||
* whenever some are left out. At least one label is always shown.
|
||||
*/
|
||||
export function getVisibleCount(
|
||||
widths: number[],
|
||||
available: number,
|
||||
gap: number,
|
||||
): number {
|
||||
const total = widths.reduce(
|
||||
(sum, width, index) => sum + width + (index > 0 ? gap : 0),
|
||||
0,
|
||||
);
|
||||
if (total <= available) {
|
||||
return widths.length;
|
||||
}
|
||||
|
||||
const limit = available - OVERFLOW_BADGE_WIDTH - gap;
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const width of widths) {
|
||||
used += width + (count > 0 ? gap : 0);
|
||||
if (used > limit) {
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
@@ -30,21 +30,23 @@ function NoResultsEmptyState({
|
||||
<div className={styles.actions}>
|
||||
{onClear && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClear}
|
||||
data-testid="no-results-clear-button"
|
||||
testId="no-results-clear-button"
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="no-results-refresh-button"
|
||||
testId="no-results-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BadgeColor } from '@signozhq/ui/badge';
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
|
||||
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
|
||||
@@ -24,9 +24,9 @@ export const SEVERITY_COLORS: Record<string, string> = {
|
||||
info: 'var(--bg-robin-500)',
|
||||
};
|
||||
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
|
||||
critical: 'error',
|
||||
error: 'error',
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColorType> = {
|
||||
critical: 'danger',
|
||||
error: 'danger',
|
||||
warning: 'warning',
|
||||
info: 'primary',
|
||||
};
|
||||
|
||||
@@ -33,36 +33,3 @@
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-header-help-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
span {
|
||||
font-family: var(--font-family-inter, Inter, sans-serif);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ function AuthHeader(): JSX.Element {
|
||||
<span className="auth-header-logo-text">SigNoz</span>
|
||||
</div>
|
||||
<Button
|
||||
className="auth-header-help-button"
|
||||
size="md"
|
||||
prefix={<LifeBuoy size={12} />}
|
||||
onClick={handleGetHelp}
|
||||
variant="solid"
|
||||
color="none"
|
||||
color="secondary"
|
||||
>
|
||||
Get Help
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { Check, Plus, X } from '@signozhq/icons';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Pill } from '@signozhq/ui/pill';
|
||||
import Input from 'components/Input';
|
||||
|
||||
import './Badges.styles.scss';
|
||||
@@ -47,18 +47,13 @@ function Badges({ tags, setTags }: AddTagsProps): JSX.Element {
|
||||
return (
|
||||
<div className="tags-container">
|
||||
{tags.map<React.ReactNode>((tag) => (
|
||||
<Badge
|
||||
<Pill.Closeable
|
||||
textTransform="none"
|
||||
key={tag}
|
||||
color="vanilla"
|
||||
style={{ userSelect: 'none' }}
|
||||
closable
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
handleClose(tag);
|
||||
}}
|
||||
onClose={() => handleClose(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
</Pill.Closeable>
|
||||
))}
|
||||
|
||||
{inputVisible && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Row, Select, Spin } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import {
|
||||
getValuesFromQueryParams,
|
||||
setQueryParamsFromOptions,
|
||||
@@ -35,8 +36,7 @@ export function FilterSelect({
|
||||
useCeleryFilterOptions(filterType);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
// Add state to track the current search input
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
@@ -66,7 +66,7 @@ export function FilterSelect({
|
||||
setQueryParamsFromOptions(
|
||||
value as string[],
|
||||
urlQuery,
|
||||
history,
|
||||
navigate,
|
||||
location,
|
||||
queryParam,
|
||||
);
|
||||
@@ -77,7 +77,6 @@ export function FilterSelect({
|
||||
handleSearch,
|
||||
shouldSetQueryParams,
|
||||
urlQuery,
|
||||
history,
|
||||
location,
|
||||
queryParam,
|
||||
onChange,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -16,8 +17,7 @@ import './CeleryTaskConfigOptions.styles.scss';
|
||||
function CeleryTaskConfigOptions(): JSX.Element {
|
||||
const { handleSearch, isFetching, options } =
|
||||
useCeleryFilterOptions('celery.task_name');
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
@@ -52,7 +52,7 @@ function CeleryTaskConfigOptions(): JSX.Element {
|
||||
setQueryParamsFromOptions(
|
||||
value,
|
||||
urlQuery,
|
||||
history,
|
||||
navigate,
|
||||
location,
|
||||
QueryParams.taskName,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
@@ -50,8 +51,7 @@ function CeleryTaskBar({
|
||||
queryEnabled: boolean;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -68,13 +68,13 @@ function CeleryTaskBar({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const [barState, setBarState] = useState<CeleryTaskState>(CeleryTaskState.All);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ViewMenuAction } from 'container/WidgetCard/config';
|
||||
@@ -54,8 +55,7 @@ function CeleryTaskGraph({
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
analyticsEvent?: string;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -83,13 +83,13 @@ function CeleryTaskGraph({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Col, Row } from 'antd';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -48,8 +49,7 @@ function CeleryTaskLatencyGraph({
|
||||
queryEnabled: boolean;
|
||||
checkIfDataExists?: (isDataAvailable: boolean) => void;
|
||||
}): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -80,13 +80,13 @@ function CeleryTaskLatencyGraph({
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
navigate(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
[dispatch, pathname, urlQuery],
|
||||
);
|
||||
|
||||
const selectedFilters = useMemo(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { History, Location } from 'history';
|
||||
import type { AppLocation, NavigateFn } from 'lib/router/types';
|
||||
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -17,13 +17,13 @@ export function getValuesFromQueryParams(
|
||||
export function setQueryParamsFromOptions(
|
||||
value: string[],
|
||||
urlQuery: URLSearchParams,
|
||||
history: History<unknown>,
|
||||
location: Location<unknown>,
|
||||
navigate: NavigateFn,
|
||||
location: AppLocation,
|
||||
queryParams: QueryParams,
|
||||
): void {
|
||||
urlQuery.set(queryParams, value.join(','));
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
history.replace(generatedUrl);
|
||||
navigate(generatedUrl, { replace: true });
|
||||
}
|
||||
|
||||
export function getFiltersFromQueryParams(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Button } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -8,7 +8,7 @@ import { MessageSquareText } from '@signozhq/icons';
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -39,9 +39,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-service-data-collected-table-tooltip {
|
||||
max-width: 280px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CloudintegrationtypesCollectedMetricDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { BarChart, Info, ScrollText } from '@signozhq/icons';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
import './CloudServiceDataCollected.styles.scss';
|
||||
|
||||
@@ -88,23 +88,15 @@ function CloudServiceDataCollected({
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
{metricsInfoTooltip && (
|
||||
<TooltipProvider>
|
||||
<TooltipSimple
|
||||
title={metricsInfoTooltip}
|
||||
side="top"
|
||||
tooltipContentProps={{
|
||||
className: 'cloud-service-data-collected-table-tooltip',
|
||||
}}
|
||||
<Tooltip title={metricsInfoTooltip} side="top">
|
||||
<span
|
||||
className="cloud-service-data-collected-table-heading-info"
|
||||
aria-label="About the metrics listed below"
|
||||
data-testid="data-collected-metrics-info"
|
||||
>
|
||||
<span
|
||||
className="cloud-service-data-collected-table-heading-info"
|
||||
aria-label="About the metrics listed below"
|
||||
data-testid="data-collected-metrics-info"
|
||||
>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</TooltipProvider>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.codeBlockSyntaxHighlighter {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-radius: 4px !important;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import SyntaxHighlighter, {
|
||||
a11yDark,
|
||||
} from 'components/MarkdownRenderer/syntaxHighlighter';
|
||||
@@ -52,16 +53,20 @@ function CodeBlock({
|
||||
data-testid="code-block-container"
|
||||
>
|
||||
{showCopyButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
prefix={isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
aria-label="Copy code"
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
|
||||
/>
|
||||
<div className={styles.copyButton}>
|
||||
<Tooltip title={isCopied ? 'Copied' : 'Copy'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
<SyntaxHighlighter
|
||||
style={a11yDark}
|
||||
|
||||
@@ -134,26 +134,27 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="create-sa-cancel-btn"
|
||||
testId="create-sa-cancel-btn"
|
||||
prefix={<X size={12} />}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<AuthZButton
|
||||
size="md"
|
||||
checks={[SACreatePermission]}
|
||||
withPortal={false}
|
||||
type="submit"
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
data-testid="create-sa-submit-btn"
|
||||
testId="create-sa-submit-btn"
|
||||
>
|
||||
Create Service Account
|
||||
</AuthZButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { submitImplicitly } from 'tests/submitImplicitly';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
@@ -60,7 +61,10 @@ describe('CreateServiceAccountModal', () => {
|
||||
await screen.findByTestId('create-sa-name-input');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,10 +76,14 @@ describe('CreateServiceAccountModal', () => {
|
||||
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
|
||||
|
||||
await user.type(nameInput, 'test');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
|
||||
await user.clear(nameInput);
|
||||
await waitFor(() => expect(submitBtn).toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful submit shows toast.success and closes modal', async () => {
|
||||
@@ -86,7 +94,9 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -102,6 +112,40 @@ describe('CreateServiceAccountModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing Enter again while creating sends one request', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let requests = 0;
|
||||
server.use(
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) => {
|
||||
requests += 1;
|
||||
return res(
|
||||
ctx.delay(100),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: {} }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId<HTMLInputElement>(
|
||||
'create-sa-name-input',
|
||||
);
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
|
||||
submitImplicitly(nameInput);
|
||||
await waitFor(() => expect(submitBtn).toHaveAttribute('aria-busy', 'true'));
|
||||
submitImplicitly(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it('shows toast.error on API error and keeps modal open', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -120,7 +164,9 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.type(nameInput, 'Dupe Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -180,7 +226,10 @@ describe('CreateServiceAccountModal', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// The footer lives outside the guard: submit is gated, Cancel still works.
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('create-sa-cancel-btn'));
|
||||
|
||||
|
||||
@@ -4,30 +4,6 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--secondary-foreground);
|
||||
background-color: var(--secondary-background);
|
||||
border: 1px solid var(--secondary-border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
border-color: var(--l1-border) !important;
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import * as timeUtils from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: '/test-path',
|
||||
}),
|
||||
};
|
||||
});
|
||||
mockAppLocation({ pathname: '/test-path' });
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
/** Lands on the trigger input. Not spread — the component takes no rest props. */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -95,6 +97,7 @@ function CustomTimePicker({
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
'data-testid': dataTestId,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
|
||||
useState('Select / Enter Time Range');
|
||||
@@ -106,7 +109,7 @@ function CustomTimePicker({
|
||||
const [inputErrorDetails, setInputErrorDetails] = useState<
|
||||
TimeRangeValidationResult['errorDetails'] | null
|
||||
>(null);
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const initialInputValueOnOpenRef = useRef<string>('');
|
||||
@@ -596,6 +599,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid={dataTestId}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
@@ -650,20 +654,20 @@ function CustomTimePicker({
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<Tooltip title={zoomOutDisabled ? undefined : 'Zoom out'}>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
disabledTooltip="Zoom out time range is limited to 1 month"
|
||||
size="md"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefix={<ZoomOut size={14} />}
|
||||
testId="zoom-out-btn"
|
||||
icon
|
||||
aria-label="Zoom out"
|
||||
variant="solid"
|
||||
color="none"
|
||||
/>
|
||||
color="secondary"
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
showRecentlyUsed: true,
|
||||
'data-testid': undefined,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -114,7 +114,7 @@ function CustomTimePickerPopoverContent({
|
||||
customDateTimeInputStatus = CustomTimePickerInputStatus.UNSET,
|
||||
inputErrorDetails,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useAppLocation();
|
||||
|
||||
const isLogsExplorerPage = useMemo(
|
||||
() => pathname === ROUTES.LOGS_EXPLORER,
|
||||
@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
|
||||
type="text"
|
||||
className="time-btns"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-chip-${option.value}`}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-option-${option.value}`}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
@@ -59,10 +60,6 @@ jest.mock('providers/Timezone', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
@@ -85,6 +82,7 @@ describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
mockAppLocation({ pathname: '/logs-explorer' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -164,6 +162,6 @@ describe('CustomTimePicker - zoom out button', () => {
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
expect(zoomOutBtn).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,12 +27,14 @@ function DetailsHeader({
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="sm"
|
||||
icon
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
></Button>
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -42,12 +42,5 @@
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,13 +308,16 @@ describe.each([
|
||||
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeDisabled();
|
||||
expect(screen.getByTestId(testId)).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
resolveDownload!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(testId)).not.toBeDisabled();
|
||||
expect(screen.getByTestId(testId)).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -69,10 +69,15 @@ export default function DownloadOptionsMenu({
|
||||
>
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={DownloadFormats.CSV}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={DownloadFormats.JSONL}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={exportFormat}
|
||||
onChange={setExportFormat}
|
||||
items={[
|
||||
{ value: DownloadFormats.CSV, label: 'csv' },
|
||||
{ value: DownloadFormats.JSONL, label: 'jsonl' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="horizontal-line" />
|
||||
@@ -80,19 +85,15 @@ export default function DownloadOptionsMenu({
|
||||
<div className="row-limit">
|
||||
<Typography.Text className="title">Number of Rows</Typography.Text>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={String(rowLimit)}
|
||||
onChange={(value): void => setRowLimit(Number(value))}
|
||||
>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.TEN_K)}>
|
||||
10k
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.THIRTY_K)}>
|
||||
30k
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.FIFTY_K)}>
|
||||
50k
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
items={[
|
||||
{ value: String(DownloadRowCounts.TEN_K), label: '10k' },
|
||||
{ value: String(DownloadRowCounts.THIRTY_K), label: '30k' },
|
||||
{ value: String(DownloadRowCounts.FIFTY_K), label: '50k' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dataSource !== DataSource.TRACES && (
|
||||
@@ -101,12 +102,15 @@ export default function DownloadOptionsMenu({
|
||||
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<RadioGroup value={columnsScope} onChange={setColumnsScope}>
|
||||
<RadioGroupItem value={DownloadColumnsScopes.ALL}>All</RadioGroupItem>
|
||||
<RadioGroupItem value={DownloadColumnsScopes.SELECTED}>
|
||||
Selected
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={columnsScope}
|
||||
onChange={setColumnsScope}
|
||||
items={[
|
||||
{ value: DownloadColumnsScopes.ALL, label: 'All' },
|
||||
{ value: DownloadColumnsScopes.SELECTED, label: 'Selected' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -115,9 +119,9 @@ export default function DownloadOptionsMenu({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Download size={16} />}
|
||||
size="md"
|
||||
width="100%"
|
||||
onClick={handleExport}
|
||||
className="export-button"
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
>
|
||||
Export
|
||||
@@ -148,13 +152,14 @@ export default function DownloadOptionsMenu({
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Download size={14} />}
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Download"
|
||||
data-testid={`periscope-btn-download-${dataSource}`}
|
||||
disabled={isDownloading}
|
||||
testId={`periscope-btn-download-${dataSource}`}
|
||||
loading={isDownloading}
|
||||
/>
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -38,18 +38,23 @@ function DeleteMemberDialog({
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
prefix={<X size={12} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
disabled={isDeleting}
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
prefix={<Trash2 size={12} />}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : title}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +112,7 @@
|
||||
&__footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
@@ -128,10 +128,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -200,6 +196,7 @@
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
padding-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__link-text-wrap {
|
||||
@@ -220,8 +217,4 @@
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +519,12 @@ function EditMemberDrawer({
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
<Badge
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
key={roleId}
|
||||
color="secondary"
|
||||
>
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
@@ -559,15 +564,15 @@ function EditMemberDrawer({
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Status</span>
|
||||
{member?.status === MemberStatus.Active ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
<Badge color="success" variant="outlined">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : member?.status === MemberStatus.Deleted ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
<Badge color="danger" variant="outlined">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
<Badge color="warning" variant="outlined">
|
||||
INVITED
|
||||
</Badge>
|
||||
)}
|
||||
@@ -575,12 +580,16 @@ function EditMemberDrawer({
|
||||
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{formatTimestamp(member?.joinedOn)}
|
||||
</Badge>
|
||||
</div>
|
||||
{!isInvited && (
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Last Modified</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{formatTimestamp(member?.updatedAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -611,55 +620,59 @@ function EditMemberDrawer({
|
||||
{!isDeleted && (
|
||||
<>
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabledTooltip={getDeleteTooltip(isRootUser, isSelf)}
|
||||
size="md"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="danger"
|
||||
prefix={<Trash2 size={12} />}
|
||||
>
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? getInviteButtonLabel(
|
||||
isLoadingTokenStatus,
|
||||
existingToken,
|
||||
isTokenExpired,
|
||||
tokenNotFound,
|
||||
)
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabledTooltip={ROOT_USER_TOOLTIP}
|
||||
size="md"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isRootUser}
|
||||
loading={isGeneratingLink || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
prefix={<RefreshCw size={12} />}
|
||||
>
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? getInviteButtonLabel(
|
||||
isLoadingTokenStatus,
|
||||
existingToken,
|
||||
isTokenExpired,
|
||||
tokenNotFound,
|
||||
)
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
prefix={<X size={14} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabledTooltip={isRootUser ? ROOT_USER_TOOLTIP : 'No changes to save'}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
disabled={!isDirty || isRootUser}
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
|
||||
@@ -45,11 +45,11 @@ function ResetLinkDialog({
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
onClick={onCopy}
|
||||
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('EditMemberDrawer', () => {
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('enables Save after editing name and calls updateUser on confirm', async () => {
|
||||
@@ -271,7 +271,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
|
||||
await user.click(saveBtn);
|
||||
|
||||
@@ -295,7 +297,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -323,7 +327,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(await screen.findByTitle('signoz-editor'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -349,7 +355,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(removeBtn);
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -505,7 +513,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Bob Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -541,7 +551,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -619,7 +631,7 @@ describe('EditMemberDrawer', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete member/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
|
||||
@@ -642,7 +654,7 @@ describe('EditMemberDrawer', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).not.toBeDisabled();
|
||||
).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -664,21 +676,21 @@ describe('EditMemberDrawer', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete member/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('disables Reset Link button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('disables Save button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ function ErrorModal({
|
||||
onClick={(): void => setVisible(true)}
|
||||
onKeyDown={undefined}
|
||||
>
|
||||
<Badge color="error">
|
||||
<Badge variant="solid" color="danger">
|
||||
<CircleAlert size={14} color={Color.BG_CHERRY_500} /> error
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Col, Popover, Row, Select, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -137,16 +137,15 @@ function ExplorerCard({
|
||||
);
|
||||
};
|
||||
|
||||
const moreOptionMenu: MenuProps = {
|
||||
items: [
|
||||
{
|
||||
key: 'delete',
|
||||
label: <Typography.Text strong>Delete</Typography.Text>,
|
||||
onClick: onDeleteHandler,
|
||||
icon: <Trash2 size="md" />,
|
||||
},
|
||||
],
|
||||
};
|
||||
const moreOptionItems: DropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
label: <Typography.Text strong>Delete</Typography.Text>,
|
||||
onClick: onDeleteHandler,
|
||||
prefix: <Trash2 size="md" />,
|
||||
},
|
||||
];
|
||||
|
||||
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
|
||||
const saveButtonIcon = isQueryUpdated ? null : <Save size="md" />;
|
||||
@@ -230,9 +229,14 @@ function ExplorerCard({
|
||||
</Popover>
|
||||
<Share2 onClick={onCopyUrlHandler} size="md" />
|
||||
{viewKey && (
|
||||
<DropdownMenuSimple menu={moreOptionMenu}>
|
||||
<Dropdown
|
||||
items={moreOptionItems}
|
||||
nativeButton
|
||||
align="end"
|
||||
side="bottom"
|
||||
>
|
||||
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
|
||||
</DropdownMenuSimple>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Space>
|
||||
</OffSetCol>
|
||||
|
||||
@@ -2,23 +2,15 @@ import { render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { viewMockData } from '../__mock__/viewData';
|
||||
import ExplorerCard from '../ExplorerCard';
|
||||
|
||||
const historyReplace = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
||||
}),
|
||||
useHistory: (): any => ({
|
||||
...jest.requireActual('react-router-dom').useHistory(),
|
||||
replace: historyReplace,
|
||||
}),
|
||||
}));
|
||||
mockAppLocation({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
|
||||
});
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { viewMockData } from '../__mock__/viewData';
|
||||
import MenuItemGenerator from '../MenuItemGenerator';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||
}),
|
||||
}));
|
||||
mockAppLocation({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||
});
|
||||
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SaveViewWithName from '../SaveViewWithName';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -26,8 +26,4 @@
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ClientExportData,
|
||||
@@ -51,36 +51,41 @@ export default function ExportMenu({
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipSimple title="Download">
|
||||
<Tooltip title="Download">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Download"
|
||||
data-testid={`export-menu-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
testId={`export-menu-${dataSource}`}
|
||||
loading={isExporting}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="export-menu-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={exportFormat}
|
||||
onChange={setExportFormat}
|
||||
items={[
|
||||
{ value: ExportFormat.Csv, label: 'csv' },
|
||||
{ value: ExportFormat.Jsonl, label: 'jsonl' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="export-button"
|
||||
width="100%"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
prefix={<Download size={16} />}
|
||||
>
|
||||
|
||||
@@ -80,6 +80,6 @@ describe('ExportMenu', () => {
|
||||
mockIsExporting = true;
|
||||
renderMenu();
|
||||
|
||||
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
|
||||
expect(screen.getByTestId(TEST_ID)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,9 +57,8 @@ function SortableField({
|
||||
</div>
|
||||
{!isRequired && (
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
|
||||
@@ -98,11 +98,15 @@
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
|
||||
> [data-slot='button'] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
|
||||
.removeBtn,
|
||||
.addBtn {
|
||||
> [data-slot='button'] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -137,14 +141,6 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.removeBtn,
|
||||
.addBtn {
|
||||
padding: 4px 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -173,6 +173,7 @@ function FieldsSelectorContent({
|
||||
{hasUnsavedChanges && (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
@@ -181,6 +182,7 @@ function FieldsSelectorContent({
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -138,7 +138,6 @@ function OtherFields({
|
||||
<span className={styles.fieldKey}>{attr.name}</span>
|
||||
{!isAtLimit && (
|
||||
<Button
|
||||
className={cx(styles.addBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
@@ -56,6 +56,21 @@ export const UnsavedChanges: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Saving: the toast raised once the edited columns are applied. */
|
||||
export const SavedToast: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.click(addField);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save changes' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Saved successfully')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the suggestion request succeeds with no columns to add. */
|
||||
export const NoResults: Story = {
|
||||
parameters: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button, Input } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
@@ -11,7 +11,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState('feedback');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const location = useLocation();
|
||||
const location = useAppLocation();
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -102,10 +102,12 @@ function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className="feedback-modal-container">
|
||||
<div className="feedback-modal-header">
|
||||
<ToggleGroupSimple
|
||||
<ToggleGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
type="single"
|
||||
value={activeTab}
|
||||
className="feedback-modal-tabs"
|
||||
onChange={setActiveTab}
|
||||
items={items}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.header-right-section-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--spacing-8);
|
||||
padding-right: var(--spacing-8);
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn-container {
|
||||
@@ -121,78 +122,26 @@
|
||||
}
|
||||
|
||||
.feedback-modal-container {
|
||||
.feedback-modal-tabs {
|
||||
width: 100%;
|
||||
.feedback-modal-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-checked {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
.tab-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.feedback-modal-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tab-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.feedback-tab {
|
||||
background-color: var(--danger-background);
|
||||
}
|
||||
|
||||
.bug-tab {
|
||||
background-color: var(--warning-background);
|
||||
}
|
||||
|
||||
.feature-tab {
|
||||
background-color: var(--primary-background);
|
||||
}
|
||||
.feedback-tab {
|
||||
background-color: var(--danger-background);
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
padding: 6px 16px;
|
||||
.bug-tab {
|
||||
background-color: var(--warning-background);
|
||||
}
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
margin: 0 !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&-active {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
border-bottom: none !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
.feature-tab {
|
||||
background-color: var(--primary-background);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user