mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-09 11:40:42 +01:00
Compare commits
4 Commits
fix/explor
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f8fa52e7c | ||
|
|
cf3662a7e5 | ||
|
|
18527e62c2 | ||
|
|
0e2adf3329 |
@@ -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
.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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -231,5 +231,7 @@ pyrightconfig.json
|
||||
|
||||
# dev
|
||||
.dev/
|
||||
.deepsec/
|
||||
.deepsec.zip
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
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/
|
||||
|
||||
@@ -338,6 +338,10 @@
|
||||
"name": "api/generated/services/dashboard",
|
||||
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
|
||||
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
|
||||
},
|
||||
{
|
||||
"name": "motion/react-client",
|
||||
"message": "[motion] motion/react-client is for React Server Components only. Use 'motion/react' instead — it provides the same exports and works correctly in client-side SPAs."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -578,17 +582,6 @@
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
|
||||
// algorithms that read worse when split up.
|
||||
"files": [
|
||||
"scripts/**"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"sonarjs/cognitive-complexity": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) 10.5px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.left-actions {
|
||||
@@ -46,6 +46,9 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
.text {
|
||||
padding-left: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -78,6 +81,8 @@
|
||||
}
|
||||
|
||||
.select-box {
|
||||
--combobox-trigger-width: auto;
|
||||
|
||||
display: flex;
|
||||
padding: 2px 5px;
|
||||
flex-direction: row;
|
||||
@@ -95,10 +100,9 @@
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
--button-variant-link-color: var(--l1-foreground);
|
||||
|
||||
.divider-filter {
|
||||
width: 1px;
|
||||
@@ -106,26 +110,8 @@
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-action-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.settings-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
.active {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Filter,
|
||||
Frown,
|
||||
RefreshCw,
|
||||
Settings2 as SettingsIcon,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -180,7 +180,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
// Helpers to reduce cognitive complexity in main render
|
||||
const renderLeftActions = (): JSX.Element => (
|
||||
<section className="left-actions">
|
||||
<Filter size="md" />
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
@@ -217,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
</Combobox>
|
||||
) : (
|
||||
displayedQueryName && (
|
||||
<Tooltip
|
||||
<TooltipSimple
|
||||
title={`Filter currently in sync with query ${displayedQueryName}`}
|
||||
>
|
||||
<Typography.Text className="sync-tag">
|
||||
{displayedQueryName}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
@@ -231,51 +230,54 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderRightActions = (): JSX.Element => (
|
||||
<section className="right-actions">
|
||||
<Tooltip title="Reset All">
|
||||
<TooltipSimple title="Reset All">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Reset All"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleReset}
|
||||
prefix={<RefreshCw className="sync-icon" size="md" />}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && (
|
||||
<Tooltip title="Collapse Filters">
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Collapse Filters"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDynamicFilters && (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
aria-label="Settings"
|
||||
className={classNames('right-action-icon-container', {
|
||||
active: isSettingsOpen,
|
||||
})}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={
|
||||
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
|
||||
<SettingsIcon
|
||||
className="settings-icon"
|
||||
data-testid="settings-icon"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isDynamicFilters &&
|
||||
(isSettingsDisabled ? (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title="Settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
className={classNames({ active: isSettingsOpen })}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
data-testid="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
@@ -40,7 +41,7 @@ function QuickFiltersLayout({
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsOpen, setIsViewsOpen] = useState(false);
|
||||
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
@@ -48,19 +49,23 @@ function QuickFiltersLayout({
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isPanelPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsOpen;
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{hasSavedViews && <SavedViewsRestore source={savedViewProps.source} />}
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
@@ -80,10 +85,12 @@ function QuickFiltersLayout({
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="saved-views-restore" data-source={source} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
@@ -87,6 +94,10 @@ describe('QuickFiltersLayout', () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-restore')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
@@ -149,6 +160,7 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
@@ -176,12 +188,14 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { SavedViewSourcePage } from 'container/SavedViews/types';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -32,8 +32,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import history from 'lib/history';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import * as motion from 'motion/react-client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -33,8 +34,6 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
@@ -59,16 +58,12 @@ function ListView({
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const {
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
panelType: panelTypeFromQueryBuilder,
|
||||
} = useQueryBuilder();
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
@@ -97,6 +92,7 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
@@ -105,6 +101,7 @@ function ListView({
|
||||
paginationConfig,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -170,12 +167,9 @@ function ListView({
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback(
|
||||
(value: string) => {
|
||||
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -22,7 +23,6 @@ import { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
@@ -57,13 +57,9 @@ function TracesView({
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
|
||||
useQueryBuilder();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const orderBy = useMemo(
|
||||
() => getListOrderBy(stagedQuery, TRACE_VIEW_DEFAULT_ORDER_BY),
|
||||
[stagedQuery],
|
||||
);
|
||||
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
|
||||
|
||||
const {
|
||||
columns,
|
||||
@@ -98,6 +94,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
@@ -106,6 +103,7 @@ function TracesView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -167,12 +165,9 @@ function TracesView({
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
const handleOrderChange = useCallback(
|
||||
(value: string): void => {
|
||||
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
const handleOrderChange = useCallback((value: string): void => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
// Without the full column set there is no pool to pick from, so the control is dropped.
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { parseListOrderBy } from 'utils/explorerUtils';
|
||||
|
||||
export const getListViewQuery = (
|
||||
stagedQuery: Query,
|
||||
@@ -12,7 +11,12 @@ export const getListViewQuery = (
|
||||
: cloneDeep(initialQueriesMap.traces);
|
||||
|
||||
const orderByPayload: OrderByPayload[] = orderBy
|
||||
? [parseListOrderBy(orderBy)]
|
||||
? [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
for (let i = 0; i < query.builder.queryData.length; i++) {
|
||||
|
||||
@@ -16,14 +16,14 @@ function LogsActionsContainer({
|
||||
showFrequencyChart,
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
onOrderChange,
|
||||
setOrderBy,
|
||||
explorerActions,
|
||||
}: {
|
||||
listQuery: any;
|
||||
showFrequencyChart: boolean;
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
onOrderChange: (value: string) => void;
|
||||
setOrderBy: (value: string) => void;
|
||||
explorerActions: ReactNode;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
@@ -78,7 +78,7 @@ function LogsActionsContainer({
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={onOrderChange}
|
||||
onChange={(value): void => setOrderBy(value)}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,6 @@ import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 } from 'uuid';
|
||||
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
|
||||
import LogsActionsContainer from './LogsActionsContainer';
|
||||
|
||||
@@ -102,8 +101,7 @@ function LogsExplorerViewsContainer({
|
||||
const currentMinTimeRef = useRef<number>(minTime);
|
||||
|
||||
// Context
|
||||
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
|
||||
useQueryBuilder();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const selectedPanelType = panelType || PANEL_TYPES.LIST;
|
||||
|
||||
@@ -113,7 +111,7 @@ function LogsExplorerViewsContainer({
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
|
||||
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
|
||||
|
||||
@@ -194,13 +192,6 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback(
|
||||
(value: string): void => {
|
||||
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const getRequestData = useCallback(
|
||||
(
|
||||
query: Query | null,
|
||||
@@ -439,7 +430,7 @@ function LogsExplorerViewsContainer({
|
||||
showFrequencyChart={showFrequencyChart}
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
onOrderChange={handleOrderChange}
|
||||
setOrderBy={setOrderBy}
|
||||
explorerActions={explorerActions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator: string;
|
||||
aggregateOperator?: string;
|
||||
initialOptions?: InitialOptions;
|
||||
}
|
||||
|
||||
|
||||
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
|
||||
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
icon={<Check size={14} />}
|
||||
color="warning"
|
||||
disabled={disabled}
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveChangesMenu;
|
||||
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
94
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
94
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { SAVE_VIEW_MODAL_TITLE } from './constants';
|
||||
import { SaveViewModalProps } from './types';
|
||||
|
||||
import styles from './SaveViewModal.module.scss';
|
||||
|
||||
const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!displayName || isSaving) {
|
||||
return;
|
||||
}
|
||||
if (await onSave(displayName)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={SAVE_VIEW_MODAL_TITLE[mode]}
|
||||
testId="save-view-modal"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
testId="save-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isSaving}
|
||||
disabled={!displayName}
|
||||
onClick={(): void => {
|
||||
void handleSave();
|
||||
}}
|
||||
testId="save-view-submit"
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form className={styles.field} onSubmit={handleSubmit}>
|
||||
<label htmlFor={NAME_INPUT_ID}>
|
||||
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
|
||||
</label>
|
||||
<Input
|
||||
id={NAME_INPUT_ID}
|
||||
placeholder="Eg. payments-view"
|
||||
value={name}
|
||||
onChange={(event): void => setName(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
testId="save-view-name"
|
||||
/>
|
||||
</form>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewModal;
|
||||
@@ -3,17 +3,59 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.isDirty {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-warning-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border-radius: var(--spacing-1);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&.isDirty {
|
||||
background: var(--callout-warning-background);
|
||||
color: var(--callout-warning-title);
|
||||
}
|
||||
}
|
||||
|
||||
.nameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--callout-warning-icon);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,140 @@
|
||||
import { Menu } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
// Temp Placeholder data till main functionality is plugged in
|
||||
const PLACEHOLDER_VIEW_NAME = 'My view';
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
const id = await createView(displayName);
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
toast.success('You have created a new view.', {
|
||||
position: SAVED_VIEW_TOAST_POSITION,
|
||||
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[createView, onOpenViews],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
}
|
||||
}, [view, updateView]);
|
||||
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
const isDirty = !!view && hasUnsavedChanges;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
className={cx(styles.header, { [styles.isDirty]: isDirty })}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
<Typography.Text className={styles.name}>
|
||||
{PLACEHOLDER_VIEW_NAME}
|
||||
</Typography.Text>
|
||||
{onOpenViews && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="All views"
|
||||
prefix={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
data-testid="saved-views-open"
|
||||
{isLoading ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title={name}>
|
||||
<span
|
||||
className={cx(styles.name, { [styles.isDirty]: isDirty })}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!view && (
|
||||
<SavedViewsIconButton
|
||||
title="Create new view"
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
)}
|
||||
{view && !isDirty && (
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={clearView}
|
||||
testId="saved-views-clear"
|
||||
/>
|
||||
)}
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
void handleUpdate();
|
||||
}}
|
||||
/>
|
||||
<SavedViewsIconButton
|
||||
title="Discard changes"
|
||||
icon={<X size={14} />}
|
||||
color="warning"
|
||||
disabled={isSaving}
|
||||
onClick={(): void => revertView(view)}
|
||||
testId="saved-views-discard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDirty && onOpenViews && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalMode && (
|
||||
<SaveViewModal
|
||||
mode={modalMode}
|
||||
isSaving={isSaving}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
41
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
41
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
// forwardRef and rest props so a menu can use it as its trigger.
|
||||
const SavedViewsIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SavedViewsIconButtonProps
|
||||
>(function SavedViewsIconButton(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
testId,
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={title}>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
color={color}
|
||||
size="icon"
|
||||
aria-label={title}
|
||||
prefix={icon}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
});
|
||||
|
||||
export default SavedViewsIconButton;
|
||||
17
frontend/src/container/SavedViews/SavedViewsRestore.tsx
Normal file
17
frontend/src/container/SavedViews/SavedViewsRestore.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
|
||||
// Outside the sidebar, so a collapsed sidebar still restores on a bare explorer.
|
||||
function SavedViewsRestore({
|
||||
source,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
}): null {
|
||||
const { selectView } = useSavedViewActions(source);
|
||||
useRestoreLastUsedView({ source, selectView });
|
||||
return null;
|
||||
}
|
||||
|
||||
export default SavedViewsRestore;
|
||||
@@ -0,0 +1,211 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
mockSavedViewsApi,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
async function openDirtyView(
|
||||
view: ReturnType<typeof makeView>,
|
||||
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
|
||||
const rendered = renderHeader(viewUrl(PATH, view));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
view.spec.displayName,
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
act(() => {
|
||||
rendered.history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: view.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
|
||||
);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
async function chooseFromSaveMenu(item: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), name);
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader saving', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves the current state under a generated name and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
|
||||
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
|
||||
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
expect(requests.created[0]).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
generateName: true,
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: 'v2',
|
||||
spec: expect.objectContaining({
|
||||
displayName: 'Slow checkout',
|
||||
panelType: 'list',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'You have created a new view.',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
);
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'Slow checkout',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the modal open and reports a failed save', async () => {
|
||||
const requests = mockSavedViewsApi([], { createStatus: 500 });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.created).toHaveLength(1);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
|
||||
await userEvent.click(screen.getByTestId('save-view-cancel'));
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('save as new creates another view from the changed state', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
|
||||
const { history } = await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Save as new view');
|
||||
|
||||
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
|
||||
await submitName('Errors copy');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: {
|
||||
displayName: string;
|
||||
queries: { spec: { filter: { expression: string } } }[];
|
||||
};
|
||||
};
|
||||
expect(body.spec.displayName).toBe('Errors copy');
|
||||
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-copy'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('update stores the changed state on the open view, keeping its name', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors]);
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { id, body } = requests.updated[0] as {
|
||||
id: string;
|
||||
body: {
|
||||
source: string;
|
||||
spec: {
|
||||
displayName: string;
|
||||
queries: { spec: { filter: { expression: string } } }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(id).toBe('view-1');
|
||||
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
|
||||
expect(body.spec.displayName).toBe('Errors');
|
||||
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
),
|
||||
);
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useActiveSavedView } from '../hooks/useActiveSavedView';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsRestore from '../SavedViewsRestore';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
mockSavedViewsApi,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
onOpenViews: (() => void) | undefined = jest.fn(),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsRestore source={SavedviewtypesSourceDTO.traces} />
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={onOpenViews}
|
||||
/>
|
||||
<RenderRecorder />
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
|
||||
const saveChanges = (): HTMLElement =>
|
||||
screen.getByTestId('saved-views-save-changes');
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() => expect(chip()).toHaveTextContent(name));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
// Each render's answer from the hook the header reads, so a state that lasts a
|
||||
// single render is seen too.
|
||||
interface RenderRecord {
|
||||
viewId?: string;
|
||||
dirty: boolean;
|
||||
// The staged query had not caught up with the url yet.
|
||||
isStaging: boolean;
|
||||
}
|
||||
let renders: RenderRecord[] = [];
|
||||
function RenderRecorder(): null {
|
||||
const { view, hasUnsavedChanges } = useActiveSavedView(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
renders.push({
|
||||
viewId: view?.id,
|
||||
dirty: hasUnsavedChanges,
|
||||
isStaging: stagedQuery?.id !== compositeQuery?.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
|
||||
renders = [];
|
||||
act(change);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
|
||||
);
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
return renders;
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows My view with create and all views when no view is open', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a skeleton while the view in the url loads', async () => {
|
||||
mockSavedViewsApi([errors], { getDelay: 150 });
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
|
||||
await waitForView('Errors');
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the open view clean, with clear and all views', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-create')).toBeNull();
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to My view when the view in the url does not load', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
expect(saveChanges()).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-open')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the view dirty when only the tab changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
act(() => {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
|
||||
history.push(`${PATH}?${params.toString()}`);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
});
|
||||
|
||||
it('discard puts the saved query and tab back', async () => {
|
||||
mockSavedViewsApi([slow]);
|
||||
const { history } = renderHeader(viewUrl(PATH, slow));
|
||||
await waitForView('Slow spans');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = true'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: slow.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
expect(urlParam(history, QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
|
||||
expect(urlParam(history, QueryParams.viewName)).toBeNull();
|
||||
const query = JSON.parse(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
);
|
||||
expect(query.builder.queryData[0].filter.expression).toBe(
|
||||
'duration_nano > 1000',
|
||||
);
|
||||
});
|
||||
|
||||
it('clear drops the view from the url but keeps the time range', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
|
||||
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reopens on a bare explorer', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a url with its own query alone', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsaved changes indicator', () => {
|
||||
it('never flashes while switching between views already loaded', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderHeader(viewUrl(PATH, slow));
|
||||
await waitForView('Slow spans');
|
||||
act(() => history.push(viewUrl(PATH, errors)));
|
||||
await waitForView('Errors');
|
||||
|
||||
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
|
||||
|
||||
expect(chip()).toHaveTextContent('Slow spans');
|
||||
expect(states.some((render) => render.isStaging)).toBe(true);
|
||||
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('holds while the query keeps changing on a dirty view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
const states = await recordRenders(() =>
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(states.some((render) => render.isStaging)).toBe(true);
|
||||
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('on metrics compares no columns and has no all views button', async () => {
|
||||
const cpu = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'CPU',
|
||||
source: SavedviewtypesSourceDTO.metrics,
|
||||
dataSource: DataSource.METRICS,
|
||||
expression: '',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
mockSavedViewsApi([cpu]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
|
||||
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
|
||||
DataSource.METRICS,
|
||||
);
|
||||
|
||||
await waitForView('CPU');
|
||||
expect(isDirty()).toBe(false);
|
||||
expect(screen.queryByTestId('saved-views-open')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { ReactElement, useEffect, useMemo } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
|
||||
export const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
// The server stores queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
function asStoredByServer(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, v]) => [key, asStoredByServer(v)] as const)
|
||||
.filter(([, v]) => v !== undefined),
|
||||
);
|
||||
}
|
||||
return value === null || value === '' || value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
export function queryWith(dataSource: DataSource, expression: string): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
export function makeView({
|
||||
id,
|
||||
displayName,
|
||||
source = SavedviewtypesSourceDTO.traces,
|
||||
dataSource = DataSource.TRACES,
|
||||
expression = 'has_error = true',
|
||||
panelType = PANEL_TYPES.LIST,
|
||||
}: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
source?: SavedviewtypesSourceDTO;
|
||||
dataSource?: DataSource;
|
||||
expression?: string;
|
||||
panelType?: PANEL_TYPES;
|
||||
}): SavedviewtypesSavedViewDTO {
|
||||
const spec = toSavedViewSpec({
|
||||
query: queryWith(dataSource, expression),
|
||||
panelType,
|
||||
displayName,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
name: `${displayName.toLowerCase()}-abc`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'test@signoz.io',
|
||||
updatedBy: 'test@signoz.io',
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
JSON.parse(JSON.stringify(spec.queries)),
|
||||
) as SavedviewtypesSavedViewSpecDTO['queries'],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// Params written the way the query builder redirect writes them.
|
||||
export function explorerUrl(
|
||||
path: string,
|
||||
{
|
||||
query,
|
||||
panelType,
|
||||
viewKey,
|
||||
relativeTime = '15m',
|
||||
}: {
|
||||
query?: Query;
|
||||
panelType?: PANEL_TYPES;
|
||||
viewKey?: string;
|
||||
relativeTime?: string;
|
||||
},
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
|
||||
);
|
||||
}
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
|
||||
}
|
||||
if (viewKey) {
|
||||
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
|
||||
}
|
||||
params.set(QueryParams.relativeTime, relativeTime);
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function viewUrl(
|
||||
path: string,
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
relativeTime?: string,
|
||||
): string {
|
||||
return explorerUrl(path, {
|
||||
query: getSavedViewQuery(view),
|
||||
panelType: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
viewKey: view.id,
|
||||
relativeTime,
|
||||
});
|
||||
}
|
||||
|
||||
export interface Requests {
|
||||
created: unknown[];
|
||||
updated: { id: string; body: unknown }[];
|
||||
}
|
||||
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
|
||||
): Requests {
|
||||
const requests: Requests = { created: [], updated: [] };
|
||||
server.use(
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
const source = req.url.searchParams.get('source');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: views.filter((view) => !source || view.source === source),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
const view = views.find((v) => v.id === req.params.id);
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.delay(getDelay),
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: view }),
|
||||
);
|
||||
}),
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.created.push(body);
|
||||
if (createStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({ status: 'success', data: { id: createdId } }),
|
||||
);
|
||||
}),
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.updated.push({ id: String(req.params.id), body });
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
// bare url, and sets the provider's tab from the url (the provider alone seeds
|
||||
// it from the raw, still JSON-quoted param).
|
||||
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
|
||||
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap[dataSource],
|
||||
PANEL_TYPES.LIST,
|
||||
dataSource,
|
||||
),
|
||||
[updateAllQueriesOperators, dataSource],
|
||||
);
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
useEffect(() => {
|
||||
handleSetConfig(panelType, dataSource);
|
||||
}, [panelType, dataSource, handleSetConfig]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderWithExplorerProviders(
|
||||
ui: ReactElement,
|
||||
initialUrl: string,
|
||||
dataSource: DataSource = DataSource.TRACES,
|
||||
): RenderResult & { history: MemoryHistory } {
|
||||
const history = createMemoryHistory({ initialEntries: [initialUrl] });
|
||||
// Parts of the explorer read window.location directly, so it follows the
|
||||
// router's history.
|
||||
const syncWindowLocation = (): void =>
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${history.location.pathname}${history.location.search}`,
|
||||
);
|
||||
syncWindowLocation();
|
||||
history.listen(syncWindowLocation);
|
||||
__setSearchParamsGetterForTest(
|
||||
() => new URLSearchParams(history.location.search),
|
||||
);
|
||||
safeNavigateMock.mockImplementation((to) => {
|
||||
history.push(
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const result = render(
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={mockStore(store.getState())}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN')}>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsAdapter>
|
||||
</CompatRouter>
|
||||
</Router>,
|
||||
);
|
||||
return { ...result, history };
|
||||
}
|
||||
|
||||
export function urlParam(history: MemoryHistory, key: string): string | null {
|
||||
return new URLSearchParams(history.location.search).get(key);
|
||||
}
|
||||
35
frontend/src/container/SavedViews/constants.ts
Normal file
35
frontend/src/container/SavedViews/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
|
||||
|
||||
// Only the logs and traces explorers have columns and formatting to save.
|
||||
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
|
||||
Record<SavedviewtypesSourceDTO, DataSource>
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
// What selecting a view writes to the url, so clearing it removes exactly these.
|
||||
export const SAVED_VIEW_URL_PARAMS = [
|
||||
QueryParams.viewKey,
|
||||
QueryParams.viewName,
|
||||
QueryParams.compositeQuery,
|
||||
QueryParams.panelTypes,
|
||||
];
|
||||
|
||||
export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
create: 'Create new view',
|
||||
saveAsNew: 'Save as new view',
|
||||
};
|
||||
|
||||
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
|
||||
|
||||
export function useActiveSavedView(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseActiveSavedViewResult {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: !!viewKey } },
|
||||
);
|
||||
const view = viewKey ? data?.data : undefined;
|
||||
|
||||
// The query builder stages the url query a render later. Until the ids
|
||||
// match, keep the last answer for this view; another view starts clean.
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const isStagedQueryCurrent =
|
||||
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
|
||||
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
|
||||
hasUnsavedChanges: false,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo((): boolean => {
|
||||
if (!view) {
|
||||
return false;
|
||||
}
|
||||
if (!isStagedQueryCurrent) {
|
||||
return (
|
||||
lastResult.current.viewId === view.id &&
|
||||
lastResult.current.hasUnsavedChanges
|
||||
);
|
||||
}
|
||||
return hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
optionsDataSource,
|
||||
options,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
|
||||
}, [view?.id, hasUnsavedChanges]);
|
||||
|
||||
return {
|
||||
view,
|
||||
isLoading: !!viewKey && isLoading,
|
||||
isError: !!viewKey && isError,
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
55
frontend/src/container/SavedViews/hooks/useLastUsedView.ts
Normal file
55
frontend/src/container/SavedViews/hooks/useLastUsedView.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
PreservedViewsInLocalStorage,
|
||||
PreservedViewType,
|
||||
} from 'container/ExplorerOptions/types';
|
||||
|
||||
import { UseLastUsedViewResult } from '../types';
|
||||
|
||||
function readLastUsedViews(): PreservedViewsInLocalStorage {
|
||||
try {
|
||||
return JSON.parse(
|
||||
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
|
||||
) as PreservedViewsInLocalStorage;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Read at call time, not held in state: the hook is used from more than one
|
||||
// place and a cached copy would bring back a view another caller just cleared.
|
||||
export function useLastUsedView(
|
||||
savedViewSource: SavedviewtypesSourceDTO,
|
||||
): UseLastUsedViewResult {
|
||||
// The bar's enum carries the same four values as the v2 source.
|
||||
const source = savedViewSource as unknown as PreservedViewType;
|
||||
|
||||
const getLastUsedViewKey = useCallback(
|
||||
(): string | undefined => readLastUsedViews()[source]?.key,
|
||||
[source],
|
||||
);
|
||||
|
||||
const setLastUsedView = useCallback(
|
||||
(id: string, displayName: string): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({
|
||||
...readLastUsedViews(),
|
||||
[source]: { key: id, value: displayName },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const clearLastUsedView = useCallback((): void => {
|
||||
const { [source]: _removed, ...rest } = readLastUsedViews();
|
||||
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
|
||||
}, [source]);
|
||||
|
||||
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { UseRestoreLastUsedViewArgs } from '../types';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
export function useRestoreLastUsedView({
|
||||
source,
|
||||
selectView,
|
||||
}: UseRestoreLastUsedViewArgs): void {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
|
||||
|
||||
// Decided on the first render, before the explorer writes its default query
|
||||
// to the url; later an empty url means the view was cleared.
|
||||
const [lastUsedViewKey] = useState(() => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
const isBare =
|
||||
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
|
||||
return isBare ? getLastUsedViewKey() : undefined;
|
||||
});
|
||||
const hasRestored = useRef(false);
|
||||
|
||||
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
|
||||
|
||||
const { data, isError } = useGetSavedView(
|
||||
{ id: lastUsedViewKey ?? '' },
|
||||
{ query: { enabled: shouldRestore } },
|
||||
);
|
||||
const view = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRestore) {
|
||||
return undefined;
|
||||
}
|
||||
if (isError) {
|
||||
hasRestored.current = true;
|
||||
clearLastUsedView();
|
||||
return undefined;
|
||||
}
|
||||
if (!view) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Deferred like the bar's restore, so it lands after the explorer has
|
||||
// written its default query to the url.
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasRestored.current = true;
|
||||
selectView(view);
|
||||
}, 0);
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
|
||||
}
|
||||
193
frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
Normal file
193
frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateGetSavedView,
|
||||
invalidateListSavedViews,
|
||||
useCreateSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_OPTIONS_DATA_SOURCE,
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
} from '../constants';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
// Columns and formatting need no handling here: with a viewKey in the url the
|
||||
// preference provider reads them from the view, and without one it falls back
|
||||
// to the user's own.
|
||||
export function useSavedViewActions(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseSavedViewActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
|
||||
useQueryBuilder();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { mutateAsync: createSavedView, isLoading: isCreating } =
|
||||
useCreateSavedView();
|
||||
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
|
||||
useUpdateSavedView();
|
||||
|
||||
const invalidateViews = useCallback(
|
||||
async (id?: string): Promise<void> => {
|
||||
await Promise.all([
|
||||
invalidateListSavedViews(queryClient),
|
||||
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
|
||||
// The saved views bar still lists through v1.
|
||||
queryClient.invalidateQueries([{ sourcepage: source }]),
|
||||
]);
|
||||
},
|
||||
[queryClient, source],
|
||||
);
|
||||
|
||||
const toCurrentSpec = useCallback(
|
||||
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
|
||||
toSavedViewSpec({
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
handleExplorerTabChange(view.spec.panelType, {
|
||||
query: getSavedViewQuery(view),
|
||||
viewKey: view.id,
|
||||
});
|
||||
setLastUsedView(view.id, view.spec.displayName);
|
||||
},
|
||||
[handleExplorerTabChange, setLastUsedView],
|
||||
);
|
||||
|
||||
// The preference provider re-reads a view's columns only when its list
|
||||
// changes, so the list is refetched as the view is applied again.
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
void invalidateListSavedViews(queryClient);
|
||||
selectView(view);
|
||||
},
|
||||
[queryClient, selectView],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
// here; the time range and everything else a view does not set stays.
|
||||
const clearView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
|
||||
safeNavigate(`${pathname}?${params.toString()}`);
|
||||
}, [clearLastUsedView, pathname, safeNavigate]);
|
||||
|
||||
const createView = useCallback(
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
generateName: true,
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: toCurrentSpec(displayName),
|
||||
},
|
||||
});
|
||||
await invalidateViews();
|
||||
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
|
||||
[QueryParams.viewKey]: data.id,
|
||||
});
|
||||
setLastUsedView(data.id, displayName);
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Could not save the view',
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[
|
||||
createSavedView,
|
||||
source,
|
||||
toCurrentSpec,
|
||||
invalidateViews,
|
||||
redirectWithQueryBuilderData,
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
],
|
||||
);
|
||||
|
||||
const updateView = useCallback(
|
||||
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Could not update the view',
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
};
|
||||
}
|
||||
83
frontend/src/container/SavedViews/types.ts
Normal file
83
frontend/src/container/SavedViews/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
export type SavedViewOptions = Pick<
|
||||
OptionsQuery,
|
||||
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
|
||||
>;
|
||||
|
||||
export interface ToSavedViewSpecArgs {
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES;
|
||||
displayName: string;
|
||||
options?: SavedViewOptions;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface HasUnsavedViewChangesArgs {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
stagedQuery: Query | null;
|
||||
panelType: PANEL_TYPES;
|
||||
options?: SavedViewOptions;
|
||||
}
|
||||
|
||||
export interface UseLastUsedViewResult {
|
||||
getLastUsedViewKey: () => string | undefined;
|
||||
setLastUsedView: (id: string, displayName: string) => void;
|
||||
clearLastUsedView: () => void;
|
||||
}
|
||||
|
||||
export interface UseActiveSavedViewResult {
|
||||
view: SavedviewtypesSavedViewDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export interface UseRestoreLastUsedViewArgs {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
selectView: UseSavedViewActionsResult['selectView'];
|
||||
}
|
||||
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew';
|
||||
|
||||
export interface SaveViewModalProps {
|
||||
mode: SaveViewModalMode;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves true when saved; the modal stays open otherwise.
|
||||
onSave: (displayName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SaveChangesMenuProps {
|
||||
disabled: boolean;
|
||||
onSaveAsNew: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsIconButtonProps {
|
||||
title: string;
|
||||
icon: ReactElement;
|
||||
testId: string;
|
||||
color?: 'secondary' | 'warning';
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findSavedView } from '../findSavedView';
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
import { getSavedViewQuery } from '../getSavedViewQuery';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
@@ -91,36 +91,3 @@ describe('getSavedViewQuery', () => {
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getSavedViewQuery } from '../getSavedViewQuery';
|
||||
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
|
||||
import { toSavedViewSpec } from '../toSavedViewSpec';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
// The server stores the queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
function asStoredByServer(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, v]) => [key, asStoredByServer(v)] as const)
|
||||
.filter(([, v]) => v !== undefined);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
return value === null || value === '' || value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
function viewFrom(
|
||||
spec: SavedviewtypesSavedViewSpecDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
JSON.parse(JSON.stringify(spec.queries)),
|
||||
) as SavedviewtypesSavedViewSpecDTO['queries'],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// What the query builder stages after the view is opened: the stored query,
|
||||
// with its defaults filled in.
|
||||
function stagedFrom(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
dataSource: DataSource,
|
||||
): Query {
|
||||
const saved = getSavedViewQuery(view);
|
||||
return {
|
||||
...saved,
|
||||
builder: {
|
||||
...saved.builder,
|
||||
queryData: saved.builder.queryData.map((queryData) => ({
|
||||
...initialQueryBuilderFormValuesMap[dataSource],
|
||||
...queryData,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ name: 'service.name', key: 'service.name' },
|
||||
{ name: 'name', key: 'name' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
const formatting = {
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
};
|
||||
|
||||
describe('hasUnsavedViewChanges', () => {
|
||||
const options = { selectColumns: columns, ...formatting };
|
||||
const view = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options,
|
||||
}),
|
||||
);
|
||||
const staged = stagedFrom(view, DataSource.TRACES);
|
||||
|
||||
it('is dirty when the columns change', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, selectColumns: [columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, selectColumns: defaultTraceSelectedColumns },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('on logs', () => {
|
||||
const logsView = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: { selectColumns: columns, ...formatting },
|
||||
}),
|
||||
);
|
||||
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
|
||||
// The explorer always adds the required logs columns.
|
||||
const shownColumns = ensureLogsRequiredColumns(columns);
|
||||
|
||||
it('is clean with the required columns the explorer adds', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { selectColumns: shownColumns, ...formatting },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the formatting changes', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: {
|
||||
selectColumns: defaultLogsSelectedColumns,
|
||||
format: 'table',
|
||||
maxLines: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores formatting on traces', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { toSavedViewSource } from '../toSavedViewSource';
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
|
||||
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function tracesQuery(): Query {
|
||||
const base = initialQueriesMap.traces;
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression: 'has_error = true' },
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'service.name',
|
||||
key: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
isIndexed: true,
|
||||
},
|
||||
{ key: 'name' },
|
||||
] as unknown as TelemetryFieldKey[];
|
||||
|
||||
describe('toSavedViewPanelType', () => {
|
||||
it('folds a dashboard only panel type to graph', () => {
|
||||
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSpec', () => {
|
||||
it('stores only the field identity of the columns, naming key only ones', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: columns,
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{ name: 'name' },
|
||||
]);
|
||||
expect(spec.display).toStrictEqual({
|
||||
format: 'raw',
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'Errors',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
|
||||
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
|
||||
expect(spec.display).toStrictEqual({ color: 'red' });
|
||||
});
|
||||
});
|
||||
8
frontend/src/container/SavedViews/utils/findSavedView.ts
Normal file
8
frontend/src/container/SavedViews/utils/findSavedView.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewQuery.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewQuery.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Preferences } from 'providers/preferences/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function withColumnNames(
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
): TelemetryFieldKey[] | null {
|
||||
if (!columns) {
|
||||
return null;
|
||||
}
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: column.name ?? column.key,
|
||||
}));
|
||||
}
|
||||
|
||||
// The columns and formatting the explorer shows for a saved view, with the
|
||||
// defaults filled in where the view has none.
|
||||
export function getViewColumnsAndFormatting(
|
||||
spec: SavedviewtypesSavedViewSpecDTO | undefined,
|
||||
dataSource: DataSource,
|
||||
): Preferences {
|
||||
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
|
||||
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
return {
|
||||
columns: ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
),
|
||||
formatting: {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return { columns: selectedFields || defaultTraceSelectedColumns };
|
||||
}
|
||||
return { columns: [] };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
|
||||
// The server drops empty values when it stores a query, while the query
|
||||
// builder fills them with defaults, so both sides lose them before comparing.
|
||||
function withoutEmptyValues(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map(withoutEmptyValues)
|
||||
.filter((item) => item !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
|
||||
.filter(([, item]) => item !== undefined);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
if (value === null || value === '' || value === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
|
||||
// side gets different defaults.
|
||||
export function hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
options,
|
||||
}: HasUnsavedViewChangesArgs): boolean {
|
||||
if (!stagedQuery) {
|
||||
return false;
|
||||
}
|
||||
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
|
||||
stagedQuery,
|
||||
panelType,
|
||||
);
|
||||
if (
|
||||
!isEqual(
|
||||
withoutEmptyValues(view.spec.queries),
|
||||
withoutEmptyValues(stagedQueries),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
dataSource,
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dataSource !== DataSource.LOGS || !formatting) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
formatting.format !== options.format ||
|
||||
formatting.maxLines !== options.maxLines ||
|
||||
formatting.fontSize !== options.fontSize
|
||||
);
|
||||
}
|
||||
23
frontend/src/container/SavedViews/utils/toSavedViewSource.ts
Normal file
23
frontend/src/container/SavedViews/utils/toSavedViewSource.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
80
frontend/src/container/SavedViews/utils/toSavedViewSpec.ts
Normal file
80
frontend/src/container/SavedViews/utils/toSavedViewSpec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
SavedviewtypesDisplayDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { ToSavedViewSpecArgs } from '../types';
|
||||
|
||||
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
|
||||
Object.values(SavedviewtypesPanelTypeDTO),
|
||||
);
|
||||
|
||||
// Explorers only produce list / trace / graph / table; anything else a caller
|
||||
// hands over (a dashboard panel type) is stored as a time series view.
|
||||
export function toSavedViewPanelType(
|
||||
panelType: PANEL_TYPES,
|
||||
): SavedviewtypesPanelTypeDTO {
|
||||
return SAVED_VIEW_PANEL_TYPES.has(panelType)
|
||||
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
|
||||
: SavedviewtypesPanelTypeDTO.graph;
|
||||
}
|
||||
|
||||
// The api stores the field identity only; `key` and the column flags are ui state.
|
||||
function toFieldKey(
|
||||
column: TelemetryFieldKey,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO {
|
||||
return {
|
||||
name: column.name ?? column.key ?? '',
|
||||
...(column.signal && {
|
||||
signal: column.signal as unknown as TelemetrytypesSignalDTO,
|
||||
}),
|
||||
...(column.fieldContext && {
|
||||
fieldContext:
|
||||
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
|
||||
}),
|
||||
...(column.fieldDataType && {
|
||||
fieldDataType:
|
||||
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
|
||||
}),
|
||||
...(column.description && { description: column.description }),
|
||||
...(column.unit && { unit: column.unit }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toSavedViewSpec({
|
||||
query,
|
||||
panelType,
|
||||
displayName,
|
||||
options,
|
||||
color,
|
||||
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
|
||||
const { queries } = mapCompositeQueryFromQuery(query, panelType);
|
||||
|
||||
const display: SavedviewtypesDisplayDTO = {
|
||||
...(color && { color }),
|
||||
...(options?.format && { format: options.format }),
|
||||
...(options?.maxLines && { maxLines: options.maxLines }),
|
||||
...(options?.fontSize && { fontSize: options.fontSize }),
|
||||
};
|
||||
|
||||
return {
|
||||
displayName,
|
||||
panelType: toSavedViewPanelType(panelType),
|
||||
requestType: panelTypeToRequestType(panelType),
|
||||
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
...(options?.selectColumns?.length && {
|
||||
selectedFields: options.selectColumns.map(toFieldKey),
|
||||
}),
|
||||
...(Object.keys(display).length > 0 && { display }),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -33,7 +34,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
@@ -66,16 +66,12 @@ function ListView({
|
||||
queryKeyRef,
|
||||
headerActions,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const {
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
panelType: panelTypeFromQueryBuilder,
|
||||
} = useQueryBuilder();
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
@@ -127,6 +123,7 @@ function ListView({
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
@@ -136,6 +133,7 @@ function ListView({
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -217,12 +215,9 @@ function ListView({
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback(
|
||||
(value: string) => {
|
||||
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { parseListOrderBy } from 'utils/explorerUtils';
|
||||
|
||||
export const getListViewQuery = (
|
||||
stagedQuery: Query,
|
||||
@@ -13,7 +12,12 @@ export const getListViewQuery = (
|
||||
: cloneDeep(initialQueriesMap.traces);
|
||||
|
||||
const orderByPayload: OrderByPayload[] = orderBy
|
||||
? [parseListOrderBy(orderBy)]
|
||||
? [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
for (let i = 0; i < query.builder.queryData.length; i++) {
|
||||
|
||||
@@ -73,32 +73,20 @@ export const useHandleExplorerTabChange = (): {
|
||||
}
|
||||
|
||||
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
|
||||
const nextViewKey = currentQueryData?.viewKey || viewKey;
|
||||
const nextViewName = currentQueryData?.viewName || viewName;
|
||||
|
||||
if (redirectToUrl) {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
}
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
|
||||
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LogsExplorer from '../index';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.LOGS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/Toolbar/Toolbar',
|
||||
() =>
|
||||
function Toolbar({
|
||||
showLiveLogs,
|
||||
onGoLive,
|
||||
}: {
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<button type="button" data-testid="go-live" onClick={onGoLive}>
|
||||
Live
|
||||
</button>
|
||||
<span data-testid="live-state">{showLiveLogs ? 'live' : 'not-live'}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/TimeSeriesView/TimeSeriesView',
|
||||
() =>
|
||||
function TimeSeriesView(): JSX.Element {
|
||||
return <div>Time Series Chart</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/LogsExplorerChart',
|
||||
() =>
|
||||
function LogsExplorerChart(): JSX.Element {
|
||||
return <div>Frequency chart content</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/LiveLogs/LiveLogsContainer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div>Live logs</div>,
|
||||
}));
|
||||
|
||||
jest.mock('constants/panelTypes', () => ({
|
||||
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
|
||||
}));
|
||||
|
||||
jest.mock('d3-interpolate', () => ({
|
||||
interpolate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): unknown => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: { maxLines: 1, format: 'table', fontSize: 'small', version: 1 },
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function page(): JSX.Element {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[ROUTES.LOGS_EXPLORER]}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Logs Explorer live mode', () => {
|
||||
// useLocation is mocked without a search string, so the tab is driven
|
||||
// through the hook that reads it from the url.
|
||||
const panelTypeSpy = jest.spyOn(
|
||||
panelTypesQueryParamHooks,
|
||||
'useGetPanelTypesQueryParam',
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
panelTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('ends when the url moves off the list tab, whatever moved it', () => {
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const { rerender } = render(page());
|
||||
|
||||
fireEvent.click(screen.getByTestId('go-live'));
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
|
||||
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.TIME_SERIES);
|
||||
rerender(page());
|
||||
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('not-live');
|
||||
});
|
||||
|
||||
it('stays on while the url keeps the list tab', () => {
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const { rerender } = render(page());
|
||||
|
||||
fireEvent.click(screen.getByTestId('go-live'));
|
||||
rerender(page());
|
||||
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
// https://virtuoso.dev/mocking-in-tests/
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import {
|
||||
@@ -130,6 +131,30 @@ describe('Logs Explorer Tests', () => {
|
||||
// expect(oldLogsCTA).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the tab the url panelTypes param names', () => {
|
||||
// useLocation is mocked without a search string in this file, so drive
|
||||
// the param through the hook that reads it.
|
||||
const panelTypeSpy = jest
|
||||
.spyOn(panelTypesQueryParamHooks, 'useGetPanelTypesQueryParam')
|
||||
.mockReturnValue(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/logs-explorer/?panelTypes=%22graph%22']}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.timeseries-view-tab')).toHaveClass(
|
||||
'active-tab',
|
||||
);
|
||||
expect(container.querySelector('.list-view-tab')).not.toHaveClass(
|
||||
'active-tab',
|
||||
);
|
||||
panelTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
// update this test properly
|
||||
it.skip('Logs Explorer Page should render with data', async () => {
|
||||
// mocking the query range API to return the logs
|
||||
|
||||
@@ -51,9 +51,13 @@ function LogsExplorer(): JSX.Element {
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(
|
||||
() => panelTypeToExplorerView[panelTypesFromUrl],
|
||||
);
|
||||
const selectedView = panelTypeToExplorerView[panelTypesFromUrl];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView !== ExplorerViews.LIST) {
|
||||
setShowLiveLogs(false);
|
||||
}
|
||||
}, [selectedView]);
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -116,15 +120,10 @@ function LogsExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
handleSetConfig(nextPanelType, DataSource.LOGS);
|
||||
setSelectedView(view);
|
||||
|
||||
if (view !== ExplorerViews.LIST) {
|
||||
setShowLiveLogs(false);
|
||||
}
|
||||
|
||||
handleExplorerTabChange(nextPanelType, querySearchParameters);
|
||||
},
|
||||
[handleSetConfig, handleExplorerTabChange, setSelectedView],
|
||||
[handleSetConfig, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
|
||||
@@ -112,8 +112,9 @@ function TracesExplorer(): JSX.Element {
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
const selectedView = useMemo(
|
||||
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
[searchParams, panelTypesFromUrl],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
@@ -139,8 +140,6 @@ function TracesExplorer(): JSX.Element {
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
|
||||
@@ -66,7 +66,6 @@ import {
|
||||
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
|
||||
|
||||
export const QueryBuilderContext = createContext<QueryBuilderContextType>({
|
||||
currentQuery: initialQueriesMap.metrics,
|
||||
@@ -183,11 +182,6 @@ export function QueryBuilderProvider({
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
const urlPanelType = readPanelTypeFromUrl(PANEL_TYPES.LIST);
|
||||
|
||||
const isRawPanel =
|
||||
urlPanelType === PANEL_TYPES.LIST || urlPanelType === PANEL_TYPES.TRACE;
|
||||
|
||||
const setupedQueryData = builder.queryData.map((item) => {
|
||||
const currentElement: IBuilderQuery = {
|
||||
...item,
|
||||
@@ -206,10 +200,8 @@ export function QueryBuilderProvider({
|
||||
|
||||
// Explorer pages: sanitize stale orderBy before first query
|
||||
const isExplorer =
|
||||
(location.pathname === ROUTES.LOGS_EXPLORER ||
|
||||
location.pathname === ROUTES.TRACES_EXPLORER ||
|
||||
location.pathname === ROUTES.AI_OBSERVABILITY_EXPLORER) &&
|
||||
!isRawPanel;
|
||||
location.pathname === ROUTES.LOGS_EXPLORER ||
|
||||
location.pathname === ROUTES.TRACES_EXPLORER;
|
||||
if (isExplorer) {
|
||||
const sanitizedOrderBy = sanitizeOrderByForExplorer(currentElement);
|
||||
return calledFromHandleRunQuery
|
||||
@@ -270,8 +262,6 @@ export function QueryBuilderProvider({
|
||||
setStagedQuery(nextQuery);
|
||||
setCurrentQuery(newQueryState);
|
||||
setQueryType(type);
|
||||
|
||||
setCalledFromHandleRunQuery(false);
|
||||
},
|
||||
[prepareQueryBuilderData],
|
||||
);
|
||||
@@ -1048,8 +1038,7 @@ export function QueryBuilderProvider({
|
||||
const handleRunQuery = useCallback(() => {
|
||||
const isExplorer =
|
||||
location.pathname === ROUTES.LOGS_EXPLORER ||
|
||||
location.pathname === ROUTES.TRACES_EXPLORER ||
|
||||
location.pathname === ROUTES.AI_OBSERVABILITY_EXPLORER;
|
||||
location.pathname === ROUTES.TRACES_EXPLORER;
|
||||
if (isExplorer) {
|
||||
setCalledFromHandleRunQuery(true);
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
const timestampOrderBy = [{ columnName: 'timestamp', order: 'desc' }];
|
||||
|
||||
const queryWithTimestampOrder = (base: Query): Query => ({
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: base.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
orderBy: timestampOrderBy,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const renderOn = (
|
||||
route: string,
|
||||
panelType: PANEL_TYPES | null,
|
||||
): ReturnType<typeof renderHook> => {
|
||||
const search = new URLSearchParams();
|
||||
if (panelType) {
|
||||
search.set('panelTypes', JSON.stringify(panelType));
|
||||
}
|
||||
__setSearchParamsGetterForTest(() => search);
|
||||
|
||||
return renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={route}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const initOn = (
|
||||
route: string,
|
||||
panelType: PANEL_TYPES | null,
|
||||
query: Query,
|
||||
): Query | null => {
|
||||
const search = new URLSearchParams();
|
||||
if (panelType) {
|
||||
search.set('panelTypes', JSON.stringify(panelType));
|
||||
}
|
||||
__setSearchParamsGetterForTest(() => search);
|
||||
|
||||
const { result } = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={route}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.initQueryBuilderData(query);
|
||||
});
|
||||
|
||||
return result.current.stagedQuery;
|
||||
};
|
||||
|
||||
describe('explorer orderBy sanitizing by panel type', () => {
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('keeps the order on the logs list panel', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
PANEL_TYPES.LIST,
|
||||
queryWithTimestampOrder(initialQueriesMap.logs),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
|
||||
});
|
||||
|
||||
it('keeps the order when panelTypes is absent, since the explorers default to list', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
null,
|
||||
queryWithTimestampOrder(initialQueriesMap.logs),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
|
||||
});
|
||||
|
||||
it('keeps the order on the traces trace panel', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
PANEL_TYPES.TRACE,
|
||||
queryWithTimestampOrder(initialQueriesMap.traces),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
|
||||
});
|
||||
|
||||
// engineering-pod#2813: a timestamp order carried from the list view into the
|
||||
// time series tab is not a group-by key or an aggregation, and errors the query.
|
||||
it('drops the order on the time series panel', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithTimestampOrder(initialQueriesMap.logs),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the order on the LLM observability list panel', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
PANEL_TYPES.LIST,
|
||||
queryWithTimestampOrder(initialQueriesMap.traces),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual(timestampOrderBy);
|
||||
});
|
||||
|
||||
it('drops the order on the LLM observability time series panel', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.AI_OBSERVABILITY_EXPLORER,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithTimestampOrder(initialQueriesMap.traces),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('drops the order on the table panel', () => {
|
||||
const staged = initOn(
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
PANEL_TYPES.TABLE,
|
||||
queryWithTimestampOrder(initialQueriesMap.traces),
|
||||
);
|
||||
|
||||
expect(staged?.builder.queryData[0].orderBy).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// handleRunQuery means "respect the query I just built" for the init it causes.
|
||||
// It used to stay set for the rest of the visit, so every later init skipped
|
||||
// sanitizing as well.
|
||||
describe('the run bypass is one-shot', () => {
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('skips sanitizing for the init after a run, and sanitizes the next one', () => {
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
act(() => {
|
||||
(result.current as ReturnType<typeof useQueryBuilder>).handleRunQuery();
|
||||
});
|
||||
|
||||
const afterRun = {
|
||||
...queryWithTimestampOrder(initialQueriesMap.logs),
|
||||
id: 'run-1',
|
||||
};
|
||||
act(() => {
|
||||
(result.current as ReturnType<typeof useQueryBuilder>).initQueryBuilderData(
|
||||
afterRun,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
(result.current as ReturnType<typeof useQueryBuilder>).stagedQuery?.builder
|
||||
.queryData[0].orderBy,
|
||||
).toStrictEqual(timestampOrderBy);
|
||||
|
||||
const next = {
|
||||
...queryWithTimestampOrder(initialQueriesMap.logs),
|
||||
id: 'run-2',
|
||||
};
|
||||
act(() => {
|
||||
(result.current as ReturnType<typeof useQueryBuilder>).initQueryBuilderData(
|
||||
next,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
(result.current as ReturnType<typeof useQueryBuilder>).stagedQuery?.builder
|
||||
.queryData[0].orderBy,
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { findSavedView, toSavedViewSource } from 'container/SavedViews/utils';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
@@ -38,43 +34,11 @@ export function usePreferenceSync({
|
||||
const [savedViewPreferences, setSavedViewPreferences] =
|
||||
useState<Preferences | null>(null);
|
||||
|
||||
const withColumnNames = (
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
): TelemetryFieldKey[] | null => {
|
||||
if (!columns) {
|
||||
return null;
|
||||
}
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: column.name ?? column.key,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const spec = savedViewId
|
||||
? findSavedView(viewsData?.data, savedViewId)?.spec
|
||||
: undefined;
|
||||
const selectedFields = spec?.selectedFields as
|
||||
| TelemetryFieldKey[]
|
||||
| undefined;
|
||||
|
||||
let columns: TelemetryFieldKey[] = [];
|
||||
let formatting: FormattingOptions | undefined;
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
columns = ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
);
|
||||
formatting = {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = selectedFields || defaultTraceSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(spec, dataSource));
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
|
||||
// We are using a reSync state because we have URL updates as well as local storage updates
|
||||
|
||||
@@ -524,3 +524,18 @@ without it the story re-renders the tree the previous walk left behind and the
|
||||
control looks dead. The endpoint that settles a transition between two steps then
|
||||
needs a plain resolver rather than `response.json`, or the Data control on
|
||||
`loading` strands the walk halfway.
|
||||
|
||||
## Screenshots
|
||||
|
||||
To check what a CSS or component change does before Chromatic runs on the PR,
|
||||
use [sbshot](https://github.com/SigNoz/sbshot). It shoots the stories, diffs two
|
||||
runs the way Chromatic does, and opens a viewer on the changes. From `frontend/`:
|
||||
|
||||
```bash
|
||||
sbshot capture . --name-run before --title Pages/ --theme dark,light
|
||||
# make the change
|
||||
sbshot capture . --name-run after --title Pages/ --theme dark,light
|
||||
sbshot diff before after --ui
|
||||
```
|
||||
|
||||
Output goes to `.sbshot/`, which is gitignored.
|
||||
|
||||
@@ -21,7 +21,7 @@ const snapPinnedScrollers = (): void => {
|
||||
|
||||
/**
|
||||
* Runs after the story's `play`, which is where both capture stacks take the
|
||||
* picture: Chromatic snapshots there, and `scripts/story-shots.mjs` waits for
|
||||
* picture: Chromatic snapshots there, and sbshot waits for
|
||||
* the same render phase. Only what Chromatic cannot do for itself lives here -
|
||||
* it already pauses animations, videos and GIFs, and waits for the network to
|
||||
* go quiet.
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DEFAULT_LIST_ORDER_BY,
|
||||
getListOrderBy,
|
||||
getRawPanelOrderBy,
|
||||
parseListOrderBy,
|
||||
setListOrderBy,
|
||||
} from 'utils/explorerUtils';
|
||||
|
||||
const withOrderBy = (
|
||||
orderBy: { columnName: string; order: string }[],
|
||||
): Query => ({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: initialQueriesMap.logs.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
orderBy,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
describe('getRawPanelOrderBy', () => {
|
||||
it('keeps row fields', () => {
|
||||
expect(
|
||||
getRawPanelOrderBy(
|
||||
withOrderBy([{ columnName: 'duration_nano', order: 'desc' }]),
|
||||
),
|
||||
).toStrictEqual([{ columnName: 'duration_nano', order: 'desc' }]);
|
||||
});
|
||||
|
||||
it('keeps a group-by key, which is a row field too', () => {
|
||||
expect(
|
||||
getRawPanelOrderBy(
|
||||
withOrderBy([{ columnName: 'service.name', order: 'asc' }]),
|
||||
),
|
||||
).toStrictEqual([{ columnName: 'service.name', order: 'asc' }]);
|
||||
});
|
||||
|
||||
// Repro: set an order on the Time Series tab, then switch to List.
|
||||
it('drops an aggregation carried in from an aggregated panel', () => {
|
||||
expect(
|
||||
getRawPanelOrderBy(withOrderBy([{ columnName: 'count()', order: 'desc' }])),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('drops the aggregated value sentinel', () => {
|
||||
expect(
|
||||
getRawPanelOrderBy(
|
||||
withOrderBy([{ columnName: '#SIGNOZ_VALUE', order: 'desc' }]),
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the usable entry and drops the rest', () => {
|
||||
expect(
|
||||
getRawPanelOrderBy(
|
||||
withOrderBy([
|
||||
{ columnName: 'count()', order: 'desc' },
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]),
|
||||
),
|
||||
).toStrictEqual([{ columnName: 'timestamp', order: 'desc' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getListOrderBy', () => {
|
||||
it('falls back to the default when the query holds only an aggregation', () => {
|
||||
expect(
|
||||
getListOrderBy(withOrderBy([{ columnName: 'count()', order: 'desc' }])),
|
||||
).toBe(DEFAULT_LIST_ORDER_BY);
|
||||
});
|
||||
|
||||
it('reads the first order entry as a select value', () => {
|
||||
expect(
|
||||
getListOrderBy(withOrderBy([{ columnName: 'timestamp', order: 'asc' }])),
|
||||
).toBe('timestamp:asc');
|
||||
});
|
||||
|
||||
it('lower-cases a hand-written direction from a deep link', () => {
|
||||
expect(
|
||||
getListOrderBy(withOrderBy([{ columnName: 'timestamp', order: 'ASC' }])),
|
||||
).toBe('timestamp:asc');
|
||||
});
|
||||
|
||||
it('shows the primary entry when the query also carries the id tiebreaker', () => {
|
||||
const query = withOrderBy([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
{ columnName: 'id', order: 'desc' },
|
||||
]);
|
||||
|
||||
expect(getListOrderBy(query)).toBe('timestamp:desc');
|
||||
});
|
||||
|
||||
it('falls back to the default when the query carries no order', () => {
|
||||
expect(getListOrderBy(withOrderBy([]))).toBe(DEFAULT_LIST_ORDER_BY);
|
||||
expect(getListOrderBy(null)).toBe(DEFAULT_LIST_ORDER_BY);
|
||||
});
|
||||
|
||||
it('falls back to the given default', () => {
|
||||
expect(getListOrderBy(withOrderBy([]), 'last_activity_time:desc')).toBe(
|
||||
'last_activity_time:desc',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseListOrderBy', () => {
|
||||
it('splits a select value into an order payload', () => {
|
||||
expect(parseListOrderBy('durationNano:asc')).toStrictEqual({
|
||||
columnName: 'durationNano',
|
||||
order: 'asc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setListOrderBy', () => {
|
||||
it('replaces the order on every queryData entry', () => {
|
||||
const base = withOrderBy([{ columnName: 'timestamp', order: 'desc' }]);
|
||||
const next = setListOrderBy(base, 'severity_text:asc');
|
||||
|
||||
next.builder.queryData.forEach((item) => {
|
||||
expect(item.orderBy).toStrictEqual([
|
||||
{ columnName: 'severity_text', order: 'asc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the query it is given', () => {
|
||||
const base = withOrderBy([{ columnName: 'timestamp', order: 'desc' }]);
|
||||
|
||||
setListOrderBy(base, 'timestamp:asc');
|
||||
|
||||
expect(base.builder.queryData[0].orderBy).toStrictEqual([
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
|
||||
|
||||
const withParam = (value?: string): void => {
|
||||
const search = new URLSearchParams();
|
||||
if (value !== undefined) {
|
||||
search.set('panelTypes', value);
|
||||
}
|
||||
__setSearchParamsGetterForTest(() => search);
|
||||
};
|
||||
|
||||
describe('readPanelTypeFromUrl', () => {
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('decodes the JSON-encoded value the app writes', () => {
|
||||
withParam(JSON.stringify(PANEL_TYPES.TIME_SERIES));
|
||||
|
||||
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
|
||||
it('accepts an unencoded value from a hand-written link', () => {
|
||||
withParam('list');
|
||||
|
||||
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.LIST);
|
||||
});
|
||||
|
||||
// engineering-pod#6158: an unguarded parse of this param crashed the page.
|
||||
it('returns the raw value instead of throwing on a malformed one', () => {
|
||||
withParam('{list');
|
||||
|
||||
expect(readPanelTypeFromUrl()).toBe('{list');
|
||||
});
|
||||
|
||||
it('returns the fallback when the param is absent', () => {
|
||||
withParam();
|
||||
|
||||
expect(readPanelTypeFromUrl(PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
|
||||
expect(readPanelTypeFromUrl()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,8 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getParsedAggregationOptionsForOrderBy } from './aggregationConverter';
|
||||
|
||||
export const DEFAULT_LIST_ORDER_BY = 'timestamp:desc';
|
||||
|
||||
// Filters only. The query keeps the order for the panel that can use it.
|
||||
export const getRawPanelOrderBy = (query: Query | null): OrderByPayload[] => {
|
||||
const queryData = query?.builder?.queryData?.[0];
|
||||
|
||||
if (!queryData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const disallowed = new Set<string>([SIGNOZ_VALUE]);
|
||||
getParsedAggregationOptionsForOrderBy(queryData).forEach((aggregation) => {
|
||||
if (aggregation?.key) {
|
||||
disallowed.add(aggregation.key);
|
||||
}
|
||||
});
|
||||
|
||||
return (queryData.orderBy ?? []).filter(
|
||||
(item) => !disallowed.has(item.columnName),
|
||||
);
|
||||
};
|
||||
|
||||
// Never write the fallback back: a view saved without an order would then read
|
||||
// as unsaved on open.
|
||||
export const getListOrderBy = (
|
||||
query: Query | null,
|
||||
fallback: string = DEFAULT_LIST_ORDER_BY,
|
||||
): string => {
|
||||
const [orderBy] = getRawPanelOrderBy(query);
|
||||
|
||||
if (!orderBy?.columnName || !orderBy?.order) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return `${orderBy.columnName}:${orderBy.order.toLowerCase()}`;
|
||||
};
|
||||
|
||||
export const parseListOrderBy = (orderBy: string): OrderByPayload => {
|
||||
const [columnName, order] = orderBy.split(':');
|
||||
|
||||
return {
|
||||
columnName: columnName || 'timestamp',
|
||||
order: order || 'desc',
|
||||
};
|
||||
};
|
||||
|
||||
export const setListOrderBy = (query: Query, orderBy: string): Query => {
|
||||
const nextOrderBy = [parseListOrderBy(orderBy)];
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
orderBy: nextOrderBy,
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// Mapping between panel types and explorer views
|
||||
export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { getUnstableCurrentSearchParams } from './getUnstableCurrentSearchParams';
|
||||
|
||||
/**
|
||||
* The param is JSON-encoded, but a hand-written link can carry it unencoded, and
|
||||
* a malformed one must not throw (engineering-pod#6158). Reads the params
|
||||
* directly, not through `useUrlQuery`, so it works outside a hook.
|
||||
*/
|
||||
export const readPanelTypeFromUrl = (
|
||||
fallback: PANEL_TYPES | null = null,
|
||||
): PANEL_TYPES | null => {
|
||||
const raw = getUnstableCurrentSearchParams().get(QueryParams.panelTypes);
|
||||
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as PANEL_TYPES;
|
||||
} catch {
|
||||
return raw as PANEL_TYPES;
|
||||
}
|
||||
};
|
||||
@@ -99,6 +99,7 @@ def pytest_addoption(parser: pytest.Parser):
|
||||
default="delete",
|
||||
help="sqlite mode",
|
||||
)
|
||||
# Keep the version defaults below in sync with pull-images in .github/workflows/e2eci.yaml.
|
||||
parser.addoption(
|
||||
"--postgres-version",
|
||||
action="store",
|
||||
|
||||
@@ -33,6 +33,8 @@ test.describe('LLM Observability — Model Pricing', () => {
|
||||
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
|
||||
|
||||
await page.getByTestId('drawer-model-id-input').fill(MODEL_NAME);
|
||||
await page.getByTestId('drawer-pattern-input').fill(MODEL_NAME);
|
||||
await page.getByTestId('drawer-pattern-add-btn').click();
|
||||
await page.getByTestId('drawer-input-cost').fill(INPUT_COST);
|
||||
await page.getByTestId('drawer-output-cost').fill(OUTPUT_COST);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user