Compare commits

..

9 Commits

Author SHA1 Message Date
Nikhil Soni
6b13794728 feat(promote): support indexes on trace attributes and remove prefix requirement in paths (#13021)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
Stacked on #12839.

## What this does

**Per-path indexes for trace attributes**
The `traces/attribute` domain now supports `indexes`, same as logs body.
Promoted attributes are indexed on `attributes_promoted`, unpromoted
ones on `attributes` — so indexing without promoting works, like it does
for logs body.

Trace index expressions are plain sub-column casts, e.g.
`attributes_promoted.http.route::String`.

**Bare paths in every domain (breaking)**
The `body.` prefix is dropped — the API URL already names the context,
so paths are bare attribute names everywhere: `body.user.name` →
`user.name`. Existing promotions stay valid (paths were always stored
bare). Prefixed paths (`body.`, `attributes.`, …) are rejected with a
guiding error.
2026-10-09 15:05:01 +00:00
Nikhil Soni
ba3ca5645e feat(promote): refactor path promotion API to support traces as well (#12839)
## Summary

- Refactors the promote module into a target-parameterized core
(`promotetypes.Target`) so the existing logs `body_v2 → body_promoted`
flow and new promotion domains share one implementation.
- Adds the spans `attributes → attributes_promoted` JSON domain behind
new endpoints `POST/GET /api/v1/traces/promote_paths`. Promotions are
recorded as
`('traces','attributes_promoted','JSON()','attribute',<name>,0,now)`
rows in `signoz_metadata.column_evolution_metadata` — the same rows the
traces field mapper already consumes to read
`attributes_promoted.<path>` per time window, so queries pick up
promoted attributes automatically (forward-only, with the usual
`multiIf` fan-out across the promotion boundary).

🤖 Generated with Kimi K3

Note: Will raise a followup for supporting indexing on trace attribute,
wanted to keep the diff clean this is more of a refactor
2026-10-09 13:05:56 +00:00
Ashwin Bhatkal
3d22976c72 feat(infra-monitoring): filter k8s lists by pod status, node readiness and container status (#12946)
#### Description

All kubernetes resource views now have support for filtering on
statuses.
- Pod status can be filtered everywhere except in Containers
- Container status can be filtered only in containers
- Node status can filtered in Nodes and Clusters

#### Screen Recording



https://github.com/user-attachments/assets/4820f0c3-be4b-4a13-9826-c1b5240b2141



#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/531
Closes https://github.com/SigNoz/pulse-pod/issues/528
2026-10-09 11:33:47 +00:00
Gaurav Tewari
059801d80d feat(trace-details): read header metadata from the trace summary API (#13065)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Trace details header reads its metadata from `GET
/api/v1/traces/{id}/summary` (#12911) instead of deriving it from the
waterfall response.



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

close -
https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=263423245&issue=SigNoz%7Cnerve-pod%7C396

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


https://github.com/user-attachments/assets/b5900a84-20b5-402a-bf3f-f137cac3c2a3




<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information



- Stack 1/4.

this is just a simple change. we will be doing ai realted change in
upcomming prs
- Also for now the error state is not their. we keep showing loading
just like span details panel (lmk if we can change this in follow up )

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-09 11:11:08 +00:00
praneeth-signoz
c44926b5f8 feat(alert-channel-integrations): integration tests using wiremock for google chat, incident.io, jira and jsm ops- #12435 (#12985)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds WireMock-backed integration tests for four alert channels: Google
Chat, incident.io, Jira, and JSM Ops.
- Each channel gets two suites: the synchronous channel-test API
(tests/alerts/) and the real firing-rule pipeline (tests/alertmanager/).
- The shared WireMock container serves real TLS (CA from
https://github.com/SigNoz/signoz/pull/12486) under network aliases for
signoz-test.atlassian.net, and api.atlassian.com. A tls-hosts container
label recreates reused containers when the host list changes.
- Google Chat covers the cardsV2 payload with deep links, verbatim
webhook url, permanent 400/403, and a 429→200 retry.
- incident.io covers the alert event shape, a single Bearer prefix on
the token, permanent 401/422, and a 429→202 retry.
- Jira covers issue create (JQL + basic auth + ADF panel), the wont-fix
JQL clause, update of an existing open issue, reopen of a done issue,
permanent 400/401, service-account routing via the api.atlassian.com
gateway, and a search 429 retry.
- JSM Ops covers alert create plus a timeline note (GenieKey auth, HTML
body), the drop-failed-note tolerance, permanent 422/401, and a create
429 retry.
- The shared webhook-verify helper gains urlPath/count/min_count
matching and a path_pattern regex variant for paths embedding the
group-hash alias.
- Fixes a harness bug: ClickHouse mounted its cluster config from pytest
basetemp, which is wiped every session, so reused containers
hot-reloaded away their cluster definition. The configs now live in the
cross-session cache.

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


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Channel-test readiness is polled (channels/test returns 404 until the
org's alertmanager registers) instead of a fixed sleep.
- Resolved-path delivery (send_resolved close/transition flows) is
deliberately out of scope: it needs a real alert to stop firing, which
is timing-heavy; the status mapping is unit-tested.

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: nikhilmantri0902 <nikhil.mantri1999@gmail.com>
Co-authored-by: Naman Verma <naman.verma@signoz.io>
Co-authored-by: Praneeth Lingam <praneethlingam@Ollys-MacBook-Pro.local>
2026-10-09 10:39:53 +00:00
Aditya Singh
5f8fa52e7c feat(saved-views): views header in the quick filters sidebar (#13011)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- real saved views header on top of the sidebar layout.. my view,
selected view and unsaved changes states per figma. create and save as
new share one modal, ✓ updates the view or saves it as new, ✕ discards
back to the saved view and the undo arrow clears it. clear only removes
what the view set, time range stays.
- unsaved changes check compares the queries in their stored v5 form
with empty values dropped like the server does.. comparing after mapping
back to the qb shape made every untouched view read dirty. no amber
flash while switching views or ticking quick filters.
- last used view reopens when the explorer is opened with nothing in the
url, decided on first render.. the default query check the bar uses
never passes, the provider fills `aggregateOperator`.
- logs and traces take the tab from the url, and logs ends live mode
whenever the tab is not the list.
- quick filters header restyled to match the views header.. ghost
buttons, design system tooltips, no funnel, same row height. shared
component so every qf page gets it.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Screenshots


https://github.com/user-attachments/assets/982f87ab-185c-4321-8056-310131e6cb98



#### Additional Information
- behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- manual pass on logs and traces, metrics smoke. restore on logs and the
settings tooltip not rechecked after the last fixes.
- old bar still has its own restore bug, it goes away with the bar.
2026-10-09 07:52:18 +00:00
Vinicius Lourenço
cf3662a7e5 fix(home): crash due to race condition on load motion (#12681)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

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

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

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

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

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

Two little optimizations:

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

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

#### Additional Information

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

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

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

View File

@@ -58,8 +58,10 @@ console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
await browser.close();
```
Screenshots are worth taking for `Default` in both themes
(`&globals=theme:light`): text extraction does not catch an unstyled page.
Screenshots are worth taking for `Default` in both themes: text extraction does
not catch an unstyled page. Take them with
[sbshot](https://github.com/SigNoz/sbshot), e.g.
`sbshot capture http://localhost:6006 --title <story title> --theme dark,light`.
## Gates

View File

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

1
.github/CODEOWNERS vendored
View File

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

View File

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

2
.gitignore vendored
View File

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

View File

@@ -3425,13 +3425,6 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4174,11 +4167,9 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4191,8 +4182,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4206,8 +4195,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/TopListPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4293,18 +4280,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4341,18 +4316,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
properties:
kind:
enum:
- signoz/TopListPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelSpec:
properties:
display:
@@ -4396,16 +4359,6 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4579,87 +4532,6 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesSource:
enum:
- user
@@ -4844,29 +4716,6 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesTopListAppearance:
properties:
showRank:
description: Numbers each row by its position in the list.
type: boolean
showShare:
description: Shows each row's share of the listed rows' total.
type: boolean
type: object
DashboardtypesTopListPanelSpec:
properties:
appearance:
$ref: '#/components/schemas/DashboardtypesTopListAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:
@@ -7907,6 +7756,8 @@ components:
type: object
PromotetypesPromotePath:
properties:
context:
type: string
indexes:
items:
$ref: '#/components/schemas/PromotetypesWrappedIndex'
@@ -7915,6 +7766,12 @@ components:
type: string
promote:
type: boolean
signal:
type: string
required:
- signal
- context
- path
type: object
PromotetypesWrappedIndex:
properties:
@@ -13583,110 +13440,6 @@ paths:
tags:
- llmpricingrules
x-signoz-stability: alpha
/api/v1/logs/promote_paths:
get:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: ListPromotedAndIndexedPaths
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: HandlePromoteAndIndexPaths
requestBody:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
/api/v1/org/preferences:
get:
deprecated: false
@@ -13858,6 +13611,152 @@ paths:
tags:
- preferences
x-signoz-stability: alpha
/api/v1/promoted_paths:
get:
deprecated: false
description: This endpoint lists the promoted paths of every JSON column, each
annotated with its signal and context. The signal, context, promoted and indexes
query parameters filter the listing.
operationId: ListPromotedPaths
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: context
schema:
type: string
- in: query
name: promoted
schema:
nullable: true
type: boolean
- in: query
name: indexes
schema:
nullable: true
type: boolean
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List promoted paths
tags:
- promote
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint promotes paths of JSON columns to their promoted
columns. Each path names its promotion target with its signal and context,
e.g. traces/attribute.
operationId: PromotePaths
requestBody:
content:
application/json:
examples:
logs_body:
summary: 'Logs body: promote a path and index it'
value:
- context: body
indexes:
- fieldDataType: string
granularity: 1
type: ngrambf_v1(4, 1024, 2, 0)
path: user.name
promote: true
signal: logs
traces_attribute:
summary: 'Traces attribute: promote a path'
value:
- context: attribute
path: http.method
promote: true
signal: traces
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false

4
frontend/.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -57,7 +57,6 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -71,7 +70,6 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

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

View File

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

View File

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

View File

@@ -4,23 +4,15 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -28,26 +20,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
@@ -149,175 +121,3 @@ export const useHandleExportRawDataPOST = <
> => {
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
url: `/api/v1/logs/promote_paths`,
method: 'GET',
signal,
});
};
export const getListPromotedAndIndexedPathsQueryKey = () => {
return [`/api/v1/logs/promote_paths`] as const;
};
export const getListPromotedAndIndexedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
>;
export type ListPromotedAndIndexedPathsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export function useListPromotedAndIndexedPaths<
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Promote and index paths
*/
export const invalidateListPromotedAndIndexedPaths = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const handlePromoteAndIndexPaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/logs/promote_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getHandlePromoteAndIndexPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['handlePromoteAndIndexPaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return handlePromoteAndIndexPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
>;
export type HandlePromoteAndIndexPathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type HandlePromoteAndIndexPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export const useHandlePromoteAndIndexPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
};

View File

@@ -0,0 +1,232 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
* @summary List promoted paths
*/
export const listPromotedPaths = (
params?: ListPromotedPathsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListPromotedPaths200>({
url: `/api/v1/promoted_paths`,
method: 'GET',
params,
signal,
});
};
export const getListPromotedPathsQueryKey = (
params?: ListPromotedPathsParams,
) => {
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
};
export const getListPromotedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedPaths>>
> = ({ signal }) => listPromotedPaths(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedPaths>>
>;
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List promoted paths
*/
export function useListPromotedPaths<
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedPathsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List promoted paths
*/
export const invalidateListPromotedPaths = async (
queryClient: QueryClient,
params?: ListPromotedPathsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedPathsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/promoted_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getPromotePathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['promotePaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof promotePaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return promotePaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type PromotePathsMutationResult = NonNullable<
Awaited<ReturnType<typeof promotePaths>>
>;
export type PromotePathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote paths
*/
export const usePromotePaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getPromotePathsMutationOptions(options));
};

View File

@@ -4372,12 +4372,6 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5378,136 +5372,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind {
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
export interface DashboardtypesTopListAppearanceDTO {
/**
* @type boolean
* @description Numbers each row by its position in the list.
*/
showRank?: boolean;
/**
* @type boolean
* @description Shows each row's share of the listed rows' total.
*/
showShare?: boolean;
}
export interface DashboardtypesTopListPanelSpecDTO {
appearance?: DashboardtypesTopListAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesComparisonThresholdDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO {
/**
* @enum signoz/TopListPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind;
spec: DashboardtypesTopListPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5518,9 +5382,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6447,8 +6309,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
'signoz/TopListPanel' = 'signoz/TopListPanel',
}
/**
* @nullable
@@ -9659,6 +9519,10 @@ export interface PromotetypesWrappedIndexDTO {
}
export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
context: string;
/**
* @type array
*/
@@ -9666,11 +9530,15 @@ export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
path?: string;
path: string;
/**
* @type boolean
*/
promote?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
@@ -12974,17 +12842,6 @@ export type ListUnmappedLLMModels200 = {
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListOrgPreferences200 = {
/**
* @type array
@@ -13010,6 +12867,40 @@ export type GetOrgPreference200 = {
export type UpdateOrgPreferencePathParameters = {
name: string;
};
export type ListPromotedPathsParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
context?: string;
/**
* @type boolean,null
* @description undefined
*/
promoted?: boolean | null;
/**
* @type boolean,null
* @description undefined
*/
indexes?: boolean | null;
};
export type ListPromotedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array

View File

@@ -21,7 +21,6 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -905,12 +904,6 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,10 +51,8 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
case PANEL_TYPES.TOP_LIST:
return 'scalar';
case PANEL_TYPES.TRACE:
return 'trace';
@@ -280,10 +278,8 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.TOP_LIST);
panelType === PANEL_TYPES.VALUE);
if (queryData.dataSource === DataSource.METRICS) {
return [

View File

@@ -36,6 +36,7 @@ import {
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
findOptionLabel,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -73,6 +74,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
preserveOptionOrder = false,
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
@@ -311,7 +313,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
]);
} else {
setVisibleOptions(
selectedValues.length > 0 && isEmpty(searchText)
selectedValues.length > 0 && isEmpty(searchText) && !preserveOptionOrder
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
: filteredOptions,
);
@@ -1475,6 +1477,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const shouldPrioritize =
selectedValues.length > 0 &&
isEmpty(searchText) &&
!preserveOptionOrder &&
!(hasSections && (allOptionShown || isAllSelected));
const processedOptions = shouldPrioritize
@@ -1805,6 +1808,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDynamicVariable,
showRetryButton,
waitingMessage,
preserveOptionOrder,
]);
// Custom handler for dropdown visibility changes
@@ -1892,7 +1896,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const { label: labelProp, value, closable, onClose } = props;
const label = showLabels
? options.find((option) => option.value === value)?.label || labelProp
? findOptionLabel(options, value) || labelProp
: labelProp;
// If the display value is the special ALL value, render the ALL tag

View File

@@ -0,0 +1,70 @@
import { findOptionLabel } from '../utils';
describe('findOptionLabel', () => {
const sectioned = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'Pending', value: 'pending' },
],
},
{
label: 'Error status',
options: [{ label: 'CrashLoopBackOff', value: 'crashloopbackoff' }],
},
];
it('resolves a label nested inside a section', () => {
expect(findOptionLabel(sectioned, 'crashloopbackoff')).toBe(
'CrashLoopBackOff',
);
});
it('resolves a label on a flat option list', () => {
expect(findOptionLabel([{ label: 'Ready', value: 'ready' }], 'ready')).toBe(
'Ready',
);
});
it('returns undefined for a value no option declares', () => {
expect(findOptionLabel(sectioned, 'nope')).toBeUndefined();
});
});
describe('findOptionLabel for overflow tags', () => {
// The `+N` tooltip and the visible tag both resolve labels this way, so a
// sectioned list must not fall back to the raw enum value in either.
it('resolves every value in a sectioned list', () => {
const sections = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'ContainerCreating', value: 'containercreating' },
],
},
{
label: 'Error status',
options: [
{ label: 'OOMKilled', value: 'oomkilled' },
{ label: 'NodeAffinity', value: 'nodeaffinity' },
],
},
];
const resolved = [
'running',
'containercreating',
'oomkilled',
'nodeaffinity',
].map((value) => findOptionLabel(sections, value) ?? value);
expect(resolved).toStrictEqual([
'Running',
'ContainerCreating',
'OOMKilled',
'NodeAffinity',
]);
});
});

View File

@@ -66,6 +66,7 @@ export interface CustomMultiSelectProps extends Omit<
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
preserveOptionOrder?: boolean;
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;

View File

@@ -69,6 +69,28 @@ export const prioritizeOrAddOptionForSingleSelect = (
return [foundOption, ...filteredOptions];
};
/**
* Options may be grouped into sections, so resolving a value's label has to look
* one level down as well — a sectioned list has no label at the top level.
*/
export const findOptionLabel = (
options: OptionData[],
value: string,
): string | undefined => {
for (const option of options) {
if (option.value === value) {
return option.label;
}
const nested = option.options?.find((subOption) => subOption.value === value);
if (nested) {
return nested.label;
}
}
return undefined;
};
export const prioritizeOrAddOptionForMultiSelect = (
options: OptionData[],
values: string[], // Only supports multiple values (string[])

View File

@@ -0,0 +1,7 @@
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}

View File

@@ -3,7 +3,7 @@ import TooltipScrollArea, {
TOOLTIP_SCROLL_CONTENT_CLASS,
} from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from '../../VariablesBar.module.scss';
import styles from './OverflowValuesTooltip.module.scss';
interface OverflowValuesTooltipProps {
/** The selected values the pill hides behind this `+N`. */

View File

@@ -223,14 +223,6 @@ function QueryAddOns({
const { handleSetQueryData, currentQuery } = useQueryBuilder();
// A panel's default limit applies only to a lone query: per-query limits can keep
// different groups and break a formula's join.
const isLoneQuery =
(currentQuery.builder?.queryData?.length ?? 1) +
(currentQuery.builder?.queryFormulas?.length ?? 0) +
(currentQuery.builder?.queryTraceOperator?.length ?? 0) <=
1;
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
@@ -526,11 +518,7 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder={
(isLoneQuery &&
resolvedFields.get(QueryBuilderField.Limit)?.placeholder) ||
'Enter limit'
}
placeholder="Enter limit"
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>
@@ -561,9 +549,6 @@ function QueryAddOns({
onChange={handleChangeOrderByKeys}
isRawQuery={isRawQuery}
isNewQueryV2
placeholder={
resolvedFields.get(QueryBuilderField.OrderBy)?.placeholder
}
/>
</div>
{!isPinned(QueryBuilderField.OrderBy) && (

View File

@@ -43,10 +43,7 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.TOP_LIST)
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}
@@ -68,9 +65,7 @@ function QueryAggregationOptions({
onChange={onChange}
queryData={queryData}
maxAggregations={
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.TOP_LIST
panelType === PANEL_TYPES.VALUE || panelType === PANEL_TYPES.PIE
? 1
: undefined
}

View File

@@ -95,10 +95,8 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE ||
panelType === PANEL_TYPES.TOP_LIST),
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],
);

View File

@@ -9,15 +9,11 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
const mockHandleChangeQueryData = jest.fn();
const mockHandleSetQueryData = jest.fn();
let mockCurrentQuery: { unit: string | undefined; builder?: unknown } = {
unit: undefined,
};
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): {
@@ -30,10 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: typeof mockCurrentQuery;
currentQuery: { unit: string | undefined };
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: mockCurrentQuery,
currentQuery: { unit: undefined },
}),
}));
@@ -49,10 +45,9 @@ jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
}));
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
OrderByFilter: ({ onChange, placeholder }: any): JSX.Element => (
OrderByFilter: ({ onChange }: any): JSX.Element => (
<button
data-testid="orderby"
data-placeholder={placeholder}
onClick={(): void => onChange([{ columnName: 'duration', order: 'desc' }])}
>
OrderByFilter
@@ -174,111 +169,6 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('shows the configured limit placeholder', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.SCATTER}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'defaulted',
placeholder: 'Default 10,000',
},
}}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10,000',
);
});
describe('a pinned limit with a default', () => {
const renderPinnedLimit = (): void => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TOP_LIST}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'pinned',
placeholder: 'Default 10',
},
}}
isForTraceOperator={false}
/>,
);
};
afterEach(() => {
mockCurrentQuery = { unit: undefined };
});
it('is open with its default as the placeholder', () => {
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10',
);
});
it('drops the default once a formula joins the query', () => {
mockCurrentQuery = {
unit: undefined,
builder: {
queryData: [baseQuery()],
queryFormulas: [{ expression: 'A * 2' }],
queryTraceOperator: [],
},
};
renderPinnedLimit();
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Enter limit',
);
});
});
it('passes a pinned Order By its placeholder', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TOP_LIST}
index={0}
fieldsConfig={{
[QueryBuilderField.OrderBy]: {
state: 'pinned',
placeholder: 'Default: value, desc',
},
}}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('orderby')).toHaveAttribute(
'data-placeholder',
'Default: value, desc',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -1,45 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -57,32 +57,6 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it("carries a pinned field's placeholder", () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'pinned', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: true,
placeholder: 'Default 10',
});
});
it('keeps a defaulted field as is, carrying its placeholder', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
placeholder: 'Default 10',
});
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -22,16 +22,11 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to,
* as a `pinned` field's may.
*/
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
export type QueryBuilderFieldRule =
| { state: 'defaulted'; placeholder: string }
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned'; placeholder?: string };
| { state: 'pinned' };
/**
* A caller's narrowing of the builder's surface. The builder works out which fields suit

View File

@@ -10,7 +10,6 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -21,8 +20,6 @@ const AVAILABLE: ResolvedQueryBuilderField = {
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'defaulted':
return { ...AVAILABLE, placeholder: rule.placeholder };
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':
@@ -33,12 +30,7 @@ function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
pinned: false,
};
case 'pinned':
return {
hidden: false,
disabled: false,
pinned: true,
...(rule.placeholder !== undefined && { placeholder: rule.placeholder }),
};
return { hidden: false, disabled: false, pinned: true };
default:
return AVAILABLE;
}

View File

@@ -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);
}
}
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -34,10 +34,8 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.TOP_LIST]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,11 +337,9 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TOP_LIST = 'top_list',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -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';

View File

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

View File

@@ -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';

View File

@@ -28,6 +28,9 @@ import {
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringContainerStatusFilter,
} from '../hooks';
import {
useInfraMonitoringFontSize,
@@ -97,6 +100,10 @@ export type K8sBaseListProps<
detailsQueryKeyPrefix: string;
};
function emptyToUndefined<T>(values: T[]): T[] | undefined {
return values.length > 0 ? values : undefined;
}
export function K8sBaseList<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
@@ -120,6 +127,9 @@ export function K8sBaseList<
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
const [podStatusFilter] = useInfraMonitoringPodStatusFilter();
const [nodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [containerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
@@ -171,6 +181,9 @@ export function K8sBaseList<
JSON.stringify(orderBy),
JSON.stringify(groupBy),
statusFilter,
podStatusFilter.join(),
nodeReadinessFilter.join(),
containerStatusFilter.join(),
...extraQueryKeyParts,
);
}, [
@@ -183,6 +196,9 @@ export function K8sBaseList<
orderBy,
groupBy,
statusFilter,
podStatusFilter,
nodeReadinessFilter,
containerStatusFilter,
extraQueryKeyParts,
]);
@@ -203,6 +219,9 @@ export function K8sBaseList<
statusFilter === 'active' || statusFilter === 'inactive'
? statusFilter
: undefined,
filterByPodStatus: emptyToUndefined(podStatusFilter),
filterByNodeReadiness: emptyToUndefined(nodeReadinessFilter),
filterByContainerStatus: emptyToUndefined(containerStatusFilter),
},
groupBy:
groupBy && groupBy.length > 0

View File

@@ -285,6 +285,9 @@ describe('K8sBaseList', () => {
expect(filters.filter).toStrictEqual({
expression: '',
filterByStatus: undefined,
filterByPodStatus: undefined,
filterByNodeReadiness: undefined,
filterByContainerStatus: undefined,
});
expect(filters.groupBy).toBeUndefined();
expect(filters.orderBy).toBeUndefined();
@@ -1668,3 +1671,61 @@ describe('K8sBaseList', () => {
});
});
});
describe('K8sBaseList status filters', () => {
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
function renderWithParams(queryParams: Record<string, string>): void {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({ data: [], total: 0, error: null });
renderComponent<TestItem>({
queryParams,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
}
it('sends the statuses named in the URL', async () => {
renderWithParams({ podStatus: 'running,crashloopbackoff' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual([
'running',
'crashloopbackoff',
]);
});
it('drops unknown statuses the API would reject', async () => {
renderWithParams({ podStatus: 'running,no_data,bogus' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual(['running']);
});
it('omits the filter entirely when no status is selected', async () => {
renderWithParams({});
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toBeUndefined();
});
});

View File

@@ -0,0 +1,21 @@
.statusFilterGroup {
display: flex;
align-items: center;
gap: 12px;
}
.statusFilter {
display: flex;
align-items: center;
gap: 8px;
}
.statusFilterLabel {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
white-space: nowrap;
}
.statusFilterControl {
min-width: 180px;
}

View File

@@ -0,0 +1,121 @@
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPodStatusFilter,
} from 'container/InfraMonitoringK8sV2/hooks';
import StatusMultiSelect from './StatusMultiSelect';
import { StatusFilterKind } from '../StatusFilterCells/useStatusFilter';
import {
CONTAINER_STATUS_FILTER_OPTIONS,
NODE_READINESS_FILTER_OPTIONS,
POD_STATUS_FILTER_OPTIONS,
} from './statusFilterOptions';
import styles from './EntityStatusFilter.module.scss';
interface StatusFilterSpec {
kind: StatusFilterKind;
/** Matches the entity's own status column header, so filter and column agree. */
label: string;
}
const POD_STATUS_SPEC: StatusFilterSpec = { kind: 'pod', label: 'Pod Status' };
const NODE_READINESS_SPEC: StatusFilterSpec = {
kind: 'node',
label: 'Node Readiness',
};
/**
* Which secondary status filters each list accepts — mirrors the filter structs in
* pkg/types/inframonitoringtypes. Volumes take none, so they are absent here.
*/
const STATUS_FILTERS_BY_ENTITY: Partial<
Record<InfraMonitoringEntity, StatusFilterSpec[]>
> = {
[InfraMonitoringEntity.PODS]: [{ kind: 'pod', label: 'Status' }],
[InfraMonitoringEntity.CONTAINERS]: [{ kind: 'container', label: 'Status' }],
[InfraMonitoringEntity.NODES]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.CLUSTERS]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.NAMESPACES]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DEPLOYMENTS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DAEMONSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.STATEFULSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.JOBS]: [POD_STATUS_SPEC],
};
const FILTER_KEY_BY_KIND: Record<StatusFilterKind, string> = {
pod: 'pod_status',
node: 'node_readiness',
container: 'container_status',
};
const OPTIONS_BY_KIND = {
pod: POD_STATUS_FILTER_OPTIONS,
node: NODE_READINESS_FILTER_OPTIONS,
container: CONTAINER_STATUS_FILTER_OPTIONS,
};
const ALL_VALUES_BY_KIND = {
pod: FILTERABLE_POD_STATUSES,
node: FILTERABLE_NODE_CONDITIONS,
container: FILTERABLE_CONTAINER_STATUSES,
};
function EntityStatusFilter(): JSX.Element | null {
const [category] = useInfraMonitoringCategory();
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeReadiness, setNodeReadiness] =
useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const specs = STATUS_FILTERS_BY_ENTITY[category as InfraMonitoringEntity];
if (!specs) {
return null;
}
const selectedByKind: Record<StatusFilterKind, string[]> = {
pod: podStatus,
node: nodeReadiness,
container: containerStatus,
};
const setterByKind: Record<StatusFilterKind, (next: string[]) => void> = {
pod: (next): void =>
void setPodStatus(next.length > 0 ? (next as typeof podStatus) : null),
node: (next): void =>
void setNodeReadiness(
next.length > 0 ? (next as typeof nodeReadiness) : null,
),
container: (next): void =>
void setContainerStatus(
next.length > 0 ? (next as typeof containerStatus) : null,
),
};
return (
<div className={styles.statusFilterGroup}>
{specs.map((spec) => (
<StatusMultiSelect
key={spec.kind}
label={spec.label}
entity={category as InfraMonitoringEntity}
filterKey={FILTER_KEY_BY_KIND[spec.kind]}
options={OPTIONS_BY_KIND[spec.kind]}
allValues={ALL_VALUES_BY_KIND[spec.kind]}
selected={selectedByKind[spec.kind]}
onChange={setterByKind[spec.kind]}
testId={`${spec.kind}-status-filter`}
/>
))}
</div>
);
}
export default EntityStatusFilter;

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
import { OptionData } from 'components/NewSelect/types';
import { findOptionLabel } from 'components/NewSelect/utils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useInfraMonitoringPageListing } from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { logInfraFilterCustomizedEvent } from '../../events';
import styles from './EntityStatusFilter.module.scss';
interface StatusMultiSelectProps<T extends string> {
label: string;
entity: InfraMonitoringEntity;
/** Key reported to analytics for this control, e.g. `pod_status`. */
filterKey: string;
options: OptionData[];
allValues: T[];
selected: T[];
onChange: (next: T[]) => void;
testId: string;
}
function StatusMultiSelect<T extends string>({
label,
entity,
filterKey,
options,
allValues,
selected,
onChange,
testId,
}: StatusMultiSelectProps<T>): JSX.Element {
const { currentQuery } = useQueryBuilder();
// No selection means no filter, which is every status — so show ALL as checked
// rather than an empty control that reads as "nothing matches".
const value = useMemo(
() => (selected.length > 0 ? selected : allValues),
[selected, allValues],
);
const handleChange = (next: string[] | string | undefined): void => {
const values = (Array.isArray(next) ? next : [next])
.filter((item): item is string => Boolean(item))
.filter((item): item is T => allValues.includes(item as T));
// Selecting everything is the same request as selecting nothing; store the
// empty form so the param drops out of the URL.
const isAll = values.length === allValues.length;
onChange(isAll ? [] : values);
logInfraFilterCustomizedEvent(
entity,
'status_filter',
currentQuery.builder.queryData[0]?.filter?.expression || '',
isAll ? [] : [filterKey],
);
};
const [, setCurrentPage] = useInfraMonitoringPageListing();
return (
<div className={styles.statusFilter}>
<span className={styles.statusFilterLabel}>{label}</span>
<CustomMultiSelect
className={styles.statusFilterControl}
data-testid={testId}
options={options}
value={value}
showSearch
showLabels
preserveOptionOrder
maxTagCount={1}
maxTagTextLength={12}
maxTagPlaceholder={(omitted): JSX.Element => (
<OverflowValuesTooltip
values={omitted.map((item) => {
// The omitted items carry the raw value as their label, since
// sections put the real one a level down.
const value = String(item.value ?? '');
return findOptionLabel(options, value) ?? value;
})}
/>
)}
placeholder={label}
onChange={(next): void => {
handleChange(next);
void setCurrentPage(1);
}}
onClear={(): void => {
onChange([]);
void setCurrentPage(1);
}}
/>
</div>
);
}
export default StatusMultiSelect;

View File

@@ -0,0 +1,64 @@
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OptionData } from 'components/NewSelect/types';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
} from '../../../hooks';
import { POD_STATUS_COLORS, POD_STATUS_LABELS } from '../../../commonUtils';
import {
CONTAINER_STATUS_COLORS,
CONTAINER_STATUS_LABELS,
} from '../../../Containers/utils';
import { NODE_CONDITION_LABELS } from '../../../Nodes/utils';
const ERROR_BADGE_COLOR: BadgeColor = 'cherry';
/**
* Splits statuses into the two sections the dropdown renders. The badge colour is
* already the severity signal in the table, so reusing it keeps the sections and
* the status column from drifting apart.
*/
function toSectionedOptions<T extends string>(
values: T[],
labels: Record<T, string>,
colors: Record<T, BadgeColor>,
): OptionData[] {
const toOption = (value: T): OptionData => ({
label: labels[value],
value,
});
const lifecycle = values.filter(
(value) => colors[value] !== ERROR_BADGE_COLOR,
);
const errors = values.filter((value) => colors[value] === ERROR_BADGE_COLOR);
return [
{ label: 'Lifecycle', options: lifecycle.map(toOption) },
{ label: 'Error status', options: errors.map(toOption) },
].filter((section) => section.options.length > 0);
}
export const POD_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_POD_STATUSES as InframonitoringtypesPodStatusDTO[],
POD_STATUS_LABELS,
POD_STATUS_COLORS,
);
export const CONTAINER_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_CONTAINER_STATUSES as InframonitoringtypesContainerStatusDTO[],
CONTAINER_STATUS_LABELS,
CONTAINER_STATUS_COLORS,
);
/** Only two values, so sections would add a heading per row. */
export const NODE_READINESS_FILTER_OPTIONS: OptionData[] = (
FILTERABLE_NODE_CONDITIONS as InframonitoringtypesNodeConditionDTO[]
).map((value) => ({ label: NODE_CONDITION_LABELS[value], value }));

View File

@@ -0,0 +1,11 @@
.statusBadgeButton {
display: inline-flex;
padding: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
// The badge sets its own cursor, so the button's never reaches it.
--badge-cursor: pointer;
}

View File

@@ -0,0 +1,57 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
import styles from './ClickableStatusBadge.module.scss';
interface ClickableStatusBadgeProps {
color: BadgeColor;
label: string;
/** The status this badge stands for, applied as the filter when clicked. */
status: string;
kind: StatusFilterKind;
rowId: string;
}
function ClickableStatusBadge({
color,
label,
status,
kind,
rowId,
}: ClickableStatusBadgeProps): JSX.Element {
const { selected, setStatusFilter } = useStatusFilter(kind);
// Clicking the status the list is already narrowed to has nothing left to
// narrow, so it undoes the filter instead — but only when this status is the
// whole filter, else it would silently drop the other selected statuses.
const isWholeFilter = selected.length === 1 && selected[0] === status;
return (
<TanStackTable.HoverTooltip
rowId={rowId}
title={isWholeFilter ? `Clear ${label} filter` : `Filter by ${label}`}
arrow
align="start"
delayDuration={300}
>
<button
type="button"
className={styles.statusBadgeButton}
data-testid={`status-badge-${status}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
setStatusFilter(isWholeFilter ? [] : [status]);
}}
>
<Badge color={color} variant="outline">
{label}
</Badge>
</button>
</TanStackTable.HoverTooltip>
);
}
export default ClickableStatusBadge;

View File

@@ -0,0 +1,33 @@
import {
GroupedStatusCounts,
StatusCountItem,
} from 'container/InfraMonitoringK8sV2/components';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
interface ClickableStatusCountsProps {
items: StatusCountItem[];
rowId: string;
kind: StatusFilterKind;
showZeroValues?: boolean;
}
function ClickableStatusCounts({
items,
rowId,
kind,
showZeroValues,
}: ClickableStatusCountsProps): JSX.Element {
const { setStatusFilter } = useStatusFilter(kind);
return (
<GroupedStatusCounts
items={items}
rowId={rowId}
showZeroValues={showZeroValues}
onSelectStatuses={setStatusFilter}
/>
);
}
export default ClickableStatusCounts;

View File

@@ -0,0 +1,67 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import ClickableStatusBadge from '../ClickableStatusBadge';
function renderBadge(
searchParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
render(
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ClickableStatusBadge
color="cherry"
label="CrashLoopBackOff"
status="crashloopbackoff"
kind="pod"
rowId="row-1"
/>
</NuqsTestingAdapter>,
);
}
describe('ClickableStatusBadge', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(() => {
onUrlUpdate.mockClear();
});
it('filters by its status when nothing is filtered', async () => {
renderBadge({}, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
it('clears the filter when its status is the whole filter', async () => {
renderBadge({ podStatus: 'crashloopbackoff' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(
onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus'),
).toBeNull();
});
});
it('still filters when its status is only part of the filter', async () => {
// Clearing here would silently drop oomkilled, which the user also picked.
renderBadge({ podStatus: 'crashloopbackoff,oomkilled' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
});

View File

@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
} from '../../../hooks';
export type StatusFilterKind = 'pod' | 'node' | 'container';
interface StatusFilter {
selected: string[];
/**
* Replaces the filter rather than adding to it — a click on a status means
* "show me these", not "add these to what I already have". Empty clears it.
*/
setStatusFilter: (statuses: string[]) => void;
}
export function useStatusFilter(kind: StatusFilterKind): StatusFilter {
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeStatus, setNodeStatus] = useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const setStatusFilter = useCallback(
(statuses: string[]): void => {
const next = statuses.length > 0 ? statuses : null;
if (kind === 'pod') {
void setPodStatus(next as InframonitoringtypesPodStatusDTO[] | null);
} else if (kind === 'node') {
void setNodeStatus(next as InframonitoringtypesNodeConditionDTO[] | null);
} else {
void setContainerStatus(
next as InframonitoringtypesContainerStatusDTO[] | null,
);
}
void setCurrentPage(1);
},
[kind, setPodStatus, setNodeStatus, setContainerStatus, setCurrentPage],
);
let selected: string[] = containerStatus;
if (kind === 'pod') {
selected = podStatus;
} else if (kind === 'node') {
selected = nodeStatus;
}
return { selected, setStatusFilter };
}

View File

@@ -6,7 +6,7 @@ import { isCustomTimeRange } from 'store/globalTime';
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle',
source: 'quick_filter' | 'search' | 'host_status_toggle' | 'status_filter',
expression: string,
extraKeys?: string[],
): void {

View File

@@ -1,4 +1,9 @@
import { ReactNode } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CustomTimeType,
Time,
@@ -17,6 +22,9 @@ export type K8sBaseFilters = {
filter: {
expression: string;
filterByStatus?: 'active' | 'inactive' | '';
filterByPodStatus?: InframonitoringtypesPodStatusDTO[];
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[];
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[];
};
groupBy?: Array<{ name: string }>;
offset?: number;

View File

@@ -38,7 +38,11 @@ async function fetchListData(
try {
const response = await listClusters(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,19 +1,19 @@
import { Color } from '@signozhq/design-tokens';
import { Boxes } from '@signozhq/icons';
import { InframonitoringtypesClusterRecordDTO } from 'api/generated/services/sigNoz.schemas';
import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -98,18 +98,21 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="node"
rowId={rowId}
items={[
{
value: row.nodeCountsByReadiness.ready,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness.notReady,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
/>
@@ -135,7 +138,8 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
rowId={rowId}
items={getPodStatusItems(row.podCountsByStatus)}
/>

View File

@@ -40,7 +40,10 @@ async function fetchListData(
try {
const response = await listContainers(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByContainerStatus: filters.filter.filterByContainerStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -9,6 +9,8 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
@@ -173,7 +175,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
if (
!row.status ||
row.status === InframonitoringtypesContainerStatusDTO.no_data
@@ -182,9 +184,13 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
}
return (
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
{CONTAINER_STATUS_LABELS[row.status]}
</Badge>
<ClickableStatusBadge
color={CONTAINER_STATUS_COLORS[row.status]}
label={CONTAINER_STATUS_LABELS[row.status]}
status={row.status}
kind="container"
rowId={rowId}
/>
);
},
},
@@ -205,7 +211,8 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="container"
items={getContainerStatusItems(row.containerCountsByStatus)}
rowId={rowId}
/>

View File

@@ -104,6 +104,20 @@ const CONTAINER_ERROR_STATUS_LABELS: Partial<
containerCannotRun: 'ContainerCannotRun',
};
const CONTAINER_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesContainerStatusDTO),
);
/** See countKeyToPodStatus in commonUtils — the counts DTO uses camelCase keys. */
function countKeyToContainerStatus(
key: string,
): InframonitoringtypesContainerStatusDTO | null {
const value = key.toLowerCase();
return CONTAINER_STATUS_VALUES.has(value)
? (value as InframonitoringtypesContainerStatusDTO)
: null;
}
export function getContainerStatusItems(
counts: InframonitoringtypesContainerCountsByStatusDTO,
): StatusCountItem[] {
@@ -111,32 +125,67 @@ export function getContainerStatusItems(
keyof typeof CONTAINER_ERROR_STATUS_LABELS
>;
const errorBreakdown = errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
statuses: [countKeyToContainerStatus(key)].filter(
(status): status is InframonitoringtypesContainerStatusDTO =>
status !== null,
),
}));
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesContainerStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesContainerStatusDTO.completed],
},
{
value: counts.waiting + counts.containerCreating,
label: 'Waiting',
color: Color.BG_AMBER_500,
breakdown: [
{ label: 'Waiting', value: counts.waiting },
{ label: 'ContainerCreating', value: counts.containerCreating },
{
label: 'Waiting',
value: counts.waiting,
statuses: [InframonitoringtypesContainerStatusDTO.waiting],
},
{
label: 'ContainerCreating',
value: counts.containerCreating,
statuses: [InframonitoringtypesContainerStatusDTO.containercreating],
},
],
statuses: [
InframonitoringtypesContainerStatusDTO.waiting,
InframonitoringtypesContainerStatusDTO.containercreating,
],
},
{
value: counts.terminated,
label: 'Terminated',
color: Color.BG_SIENNA_500,
statuses: [InframonitoringtypesContainerStatusDTO.terminated],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesContainerStatusDTO.unknown],
},
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
})),
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
},
];
}

View File

@@ -42,7 +42,10 @@ async function fetchListData(
try {
const response = await listDaemonSets(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -126,7 +127,8 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -46,7 +46,10 @@ async function fetchListData(
try {
const response = await listDeployments(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -124,7 +125,8 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -18,6 +18,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
GetClustersQuickFiltersConfig,
GetContainersQuickFiltersConfig,
@@ -36,9 +37,12 @@ import {
} from './constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringGroupBy,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -60,6 +64,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [, setPodStatusFilter] = useInfraMonitoringPodStatusFilter();
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -219,6 +226,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -326,7 +336,10 @@ export default function InfraMonitoringK8s(): JSX.Element {
showFilters ? styles.listContainerFiltersVisible : ''
}`}
>
<K8sDynamicList controlListPrefix={showFiltersComp} />
<K8sDynamicList
controlListPrefix={showFiltersComp}
leftFilters={<EntityStatusFilter />}
/>
</div>
</div>
</div>

View File

@@ -42,7 +42,10 @@ async function fetchListData(
try {
const response = await listJobs(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -118,7 +119,8 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -44,7 +44,10 @@ async function fetchListData(
try {
const response = await listNamespaces(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,14 +4,11 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -119,7 +116,8 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -39,7 +39,11 @@ async function fetchListData(
try {
const response = await listNodes(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,20 +1,21 @@
import { Color } from '@signozhq/design-tokens';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeRecordDTO } from 'api/generated/services/sigNoz.schemas';
import {
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesNodeRecordDTO,
} from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';
export function getK8sNodeRowKey(
@@ -31,18 +32,6 @@ export function getK8sNodeItemKey(
return node.nodeName;
}
const NODE_CONDITION_COLORS: Record<string, BadgeColor> = {
ready: 'forest',
not_ready: 'amber',
no_data: 'secondary',
};
const NODE_CONDITION_LABEL_MAP: Record<string, string> = {
ready: 'Ready',
not_ready: 'Not Ready',
no_data: 'No Data',
};
export type NodeTableColumnConfig =
TableColumnDef<InframonitoringtypesNodeRecordDTO>;
export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
@@ -107,24 +96,31 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
}
return (
<Badge color={color} variant="outline">
{NODE_CONDITION_LABEL_MAP[row.condition] || 'Unknown'}
</Badge>
<ClickableStatusBadge
color={color}
label={NODE_CONDITION_LABELS[row.condition] || 'Unknown'}
status={row.condition}
kind="node"
rowId={rowId}
/>
);
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="node"
items={[
{
value: row.nodeCountsByReadiness?.ready ?? 0,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness?.notReady ?? 0,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
rowId={rowId}
@@ -149,7 +145,8 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -0,0 +1,20 @@
import { BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeConditionDTO } from 'api/generated/services/sigNoz.schemas';
export const NODE_CONDITION_COLORS: Record<
InframonitoringtypesNodeConditionDTO,
BadgeColor
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'forest',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'amber',
[InframonitoringtypesNodeConditionDTO.no_data]: 'secondary',
};
export const NODE_CONDITION_LABELS: Record<
InframonitoringtypesNodeConditionDTO,
string
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'Ready',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'Not Ready',
[InframonitoringtypesNodeConditionDTO.no_data]: 'No Data',
};

View File

@@ -36,7 +36,10 @@ async function fetchListData(
try {
const response = await listPods(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,5 +1,4 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesPodStatusDTO,
@@ -10,15 +9,17 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import {
formatBytes,
getPodStatusItems,
POD_STATUS_COLORS,
POD_STATUS_LABELS,
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
@@ -100,7 +101,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
if (!row.podStatus) {
return <></>;
}
@@ -110,11 +111,14 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
}
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
const label = row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
return (
<Badge color={color} variant="outline">
{label}
</Badge>
<ClickableStatusBadge
color={color}
label={POD_STATUS_LABELS[row.podStatus]}
status={row.podStatus}
kind="pod"
rowId={rowId}
/>
);
},
},
@@ -136,7 +140,8 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -42,7 +42,10 @@ async function fetchListData(
try {
const response = await listStatefulSets(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -127,7 +128,8 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -69,6 +69,42 @@ function renderPage(
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes with a status filter applied', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{
category: K8sCategories.CLUSTERS,
podStatus: 'crashloopbackoff',
nodeReadiness: 'not_ready',
},
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`);
});
it('should drop the status filters the new category cannot apply', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.CONTAINERS}`));
// Containers filter on their own statuses, so a pod status carried over from
// clusters would sit in the toolbar looking applied while matching nothing.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) =>
call[0].searchParams.get('category') === K8sCategories.CONTAINERS,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('podStatus')).toBeNull();
expect(categorySwitch?.[0].searchParams.get('nodeReadiness')).toBeNull();
});
});
});
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();

View File

@@ -0,0 +1,87 @@
import {
InframonitoringtypesContainerCountsByStatusDTO,
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesPodCountsByStatusDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPodStatusItems } from '../commonUtils';
import { getContainerStatusItems } from '../Containers/utils';
const POD_COUNT_KEYS: Array<keyof InframonitoringtypesPodCountsByStatusDTO> = [
'running',
'completed',
'pending',
'unknown',
'failed',
'crashLoopBackOff',
'imagePullBackOff',
'errImagePull',
'createContainerConfigError',
'containerCreating',
'oomKilled',
'error',
'containerCannotRun',
'evicted',
'nodeAffinity',
'nodeLost',
'shutdown',
'unexpectedAdmissionError',
];
function podCounts(): InframonitoringtypesPodCountsByStatusDTO {
return POD_COUNT_KEYS.reduce(
(acc, key) => ({ ...acc, [key]: 1 }),
{} as InframonitoringtypesPodCountsByStatusDTO,
);
}
function containerCounts(): InframonitoringtypesContainerCountsByStatusDTO {
return {
running: 1,
waiting: 1,
terminated: 1,
completed: 1,
unknown: 1,
containerCreating: 1,
crashLoopBackOff: 1,
imagePullBackOff: 1,
errImagePull: 1,
createContainerConfigError: 1,
oomKilled: 1,
error: 1,
containerCannotRun: 1,
} as InframonitoringtypesContainerCountsByStatusDTO;
}
describe('status counts carry filterable statuses', () => {
it('maps every pod count bucket to a real status enum member', () => {
const items = getPodStatusItems(podCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(InframonitoringtypesPodStatusDTO) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
it('gives the pod Error Status chip every error bucket it sums', () => {
const items = getPodStatusItems(podCounts());
const errorItem = items.find((item) => item.label === 'Error Status');
// The chip's own value is the sum of its breakdown, so selecting it must
// select every status counted in that sum.
expect(errorItem?.value).toBe(errorItem?.breakdown?.length);
expect(errorItem?.statuses).toHaveLength(errorItem?.breakdown?.length ?? 0);
});
it('maps every container count bucket to a real status enum member', () => {
const items = getContainerStatusItems(containerCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(
InframonitoringtypesContainerStatusDTO,
) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
});

View File

@@ -0,0 +1,68 @@
import * as inframonitoring from 'api/generated/services/inframonitoring';
import { entityRegistry } from '../Base/entity.registry';
import { K8sCategories } from '../constants';
jest.mock('api/generated/services/inframonitoring');
/**
* Every list the status filter offers must forward the flag, or the control is a
* no-op on that section. Mirrors the filter structs in pkg/types/inframonitoringtypes.
*/
const EXPECTED_FLAGS: Record<string, string[]> = {
[K8sCategories.PODS]: ['filterByPodStatus'],
[K8sCategories.CONTAINERS]: ['filterByContainerStatus'],
[K8sCategories.NODES]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.CLUSTERS]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.NAMESPACES]: ['filterByPodStatus'],
[K8sCategories.DEPLOYMENTS]: ['filterByPodStatus'],
[K8sCategories.DAEMONSETS]: ['filterByPodStatus'],
[K8sCategories.STATEFULSETS]: ['filterByPodStatus'],
[K8sCategories.JOBS]: ['filterByPodStatus'],
};
const SENTINELS: Record<string, string[]> = {
filterByPodStatus: ['crashloopbackoff'],
filterByNodeReadiness: ['not_ready'],
filterByContainerStatus: ['oomkilled'],
};
describe('status filters reach the list APIs', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.values(inframonitoring).forEach((exported) => {
if (jest.isMockFunction(exported)) {
exported.mockResolvedValue({
data: { type: 'list', records: [], total: 0 },
});
}
});
});
Object.entries(EXPECTED_FLAGS).forEach(([category, flags]) => {
it(`forwards ${flags.join(' + ')} for ${category}`, async () => {
const config = entityRegistry[category];
expect(config).toBeDefined();
await config.list.fetchListData({
filter: {
expression: '',
...Object.fromEntries(flags.map((flag) => [flag, SENTINELS[flag]])),
},
start: 1,
end: 2,
limit: 10,
offset: 0,
});
const call = Object.values(inframonitoring)
.filter(jest.isMockFunction)
.flatMap((fn) => (fn as jest.Mock).mock.calls)[0];
expect(call).toBeDefined();
flags.forEach((flag) => {
expect(call[0].filter[flag]).toStrictEqual(SENTINELS[flag]);
});
});
});
});

View File

@@ -51,6 +51,34 @@ export const POD_STATUS_COLORS: Record<
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]: 'cherry',
};
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
export const POD_STATUS_LABELS: Record<
InframonitoringtypesPodStatusDTO,
string
> = {
[InframonitoringtypesPodStatusDTO.running]: 'Running',
[InframonitoringtypesPodStatusDTO.completed]: 'Completed',
[InframonitoringtypesPodStatusDTO.pending]: 'Pending',
[InframonitoringtypesPodStatusDTO.unknown]: 'Unknown',
[InframonitoringtypesPodStatusDTO.no_data]: 'No data',
[InframonitoringtypesPodStatusDTO.failed]: 'Failed',
[InframonitoringtypesPodStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
[InframonitoringtypesPodStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
[InframonitoringtypesPodStatusDTO.errimagepull]: 'ErrImagePull',
[InframonitoringtypesPodStatusDTO.createcontainerconfigerror]:
'CreateContainerConfigError',
[InframonitoringtypesPodStatusDTO.containercreating]: 'ContainerCreating',
[InframonitoringtypesPodStatusDTO.oomkilled]: 'OOMKilled',
[InframonitoringtypesPodStatusDTO.error]: 'Error',
[InframonitoringtypesPodStatusDTO.containercannotrun]: 'ContainerCannotRun',
[InframonitoringtypesPodStatusDTO.evicted]: 'Evicted',
[InframonitoringtypesPodStatusDTO.nodeaffinity]: 'NodeAffinity',
[InframonitoringtypesPodStatusDTO.nodelost]: 'NodeLost',
[InframonitoringtypesPodStatusDTO.shutdown]: 'Shutdown',
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]:
'UnexpectedAdmissionError',
};
type PodStatusCategory =
| 'running'
| 'completed'
@@ -105,6 +133,24 @@ const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {
unexpectedAdmissionError: 'UnexpectedAdmissionError',
};
const POD_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesPodStatusDTO),
);
/**
* The count DTO spells each status in camelCase (`crashLoopBackOff`) where the
* status enum spells it lowercase (`crashloopbackoff`); anything that does not
* land on a real enum member is dropped rather than sent to the API.
*/
function countKeyToPodStatus(
key: string,
): InframonitoringtypesPodStatusDTO | null {
const value = key.toLowerCase();
return POD_STATUS_VALUES.has(value)
? (value as InframonitoringtypesPodStatusDTO)
: null;
}
export function getPodStatusItems(
counts: InframonitoringtypesPodCountsByStatusDTO,
): StatusCountItem[] {
@@ -114,18 +160,42 @@ export function getPodStatusItems(
const errorBreakdown = errorKeys.map((key) => ({
label: ERROR_STATUS_LABELS[key],
value: counts[key],
statuses: [countKeyToPodStatus(key)].filter(
(status): status is InframonitoringtypesPodStatusDTO => status !== null,
),
}));
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{ value: counts.pending, label: 'Pending', color: Color.BG_AMBER_500 },
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesPodStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesPodStatusDTO.completed],
},
{
value: counts.pending,
label: 'Pending',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesPodStatusDTO.pending],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesPodStatusDTO.unknown],
},
{
value: errorTotal,
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
},
];
}

View File

@@ -45,3 +45,25 @@
.tooltipValue {
font-variant-numeric: tabular-nums;
}
.itemSelectable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.tooltipRowSelectable {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
text-align: left;
&:hover {
text-decoration: underline;
}
}

View File

@@ -8,6 +8,8 @@ import { MouseEventHandler } from 'react';
export interface StatusBreakdownItem {
label: string;
value: number;
/** The status values this row stands for, for click-to-filter. */
statuses?: string[];
}
export interface StatusCountItem {
@@ -15,15 +17,22 @@ export interface StatusCountItem {
label: string;
color: string;
breakdown?: StatusBreakdownItem[];
/** The status values this count stands for, for click-to-filter. */
statuses?: string[];
}
interface GroupedStatusCountsProps {
items: StatusCountItem[];
rowId: string;
showZeroValues?: boolean;
/** Omit to leave the counts inert, as on entities with no status filter. */
onSelectStatuses?: (statuses: string[]) => void;
}
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
function buildTooltipContent(
item: StatusCountItem,
onSelectStatuses?: (statuses: string[]) => void,
): React.ReactNode {
const onClickHandle: MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
@@ -57,14 +66,33 @@ function buildTooltipContent(item: StatusCountItem): React.ReactNode {
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
{nonZeroBreakdown.map((b) => (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
))}
{nonZeroBreakdown.map((b) =>
onSelectStatuses && b.statuses?.length ? (
<button
key={b.label}
type="button"
className={`${styles.tooltipRow} ${styles.tooltipRowSelectable}`}
data-testid={`status-breakdown-${b.statuses.join('-')}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(b.statuses as string[]);
}}
>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</button>
) : (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
),
)}
</div>
);
}
@@ -73,6 +101,7 @@ export function GroupedStatusCounts({
items,
rowId,
showZeroValues = true,
onSelectStatuses,
}: GroupedStatusCountsProps): JSX.Element {
const visibleItems =
showZeroValues === false ? items.filter((item) => item.value > 0) : items;
@@ -87,14 +116,30 @@ export function GroupedStatusCounts({
<TanStackTable.HoverTooltip
key={item.label}
rowId={rowId}
title={buildTooltipContent(item)}
title={buildTooltipContent(item, onSelectStatuses)}
arrow
align="start"
>
{item.value ? (
<TanStackTable.Text
className={styles.item}
className={`${styles.item} ${
onSelectStatuses && item.statuses?.length ? styles.itemSelectable : ''
}`}
style={{ '--gsc-color': item.color } as React.CSSProperties}
data-testid={
item.statuses?.length
? `status-count-${item.statuses.join('-')}`
: undefined
}
onClick={
onSelectStatuses && item.statuses?.length
? (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(item.statuses as string[]);
}
: undefined
}
>
{item.value}
</TanStackTable.Text>

View File

@@ -971,6 +971,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
STATUS_FILTER: 'statusFilter',
POD_STATUS_FILTER: 'podStatus',
NODE_READINESS_FILTER: 'nodeReadiness',
CONTAINER_STATUS_FILTER: 'containerStatus',
CURRENT_PAGE: 'currentPage',
PAGE: 'page',
PAGE_SIZE: 'pageSize',

View File

@@ -1,13 +1,20 @@
import {
createParser,
Options,
parseAsArrayOf,
parseAsInteger,
parseAsJson,
parseAsString,
parseAsStringEnum,
useQueryState,
useQueryStates,
UseQueryStateReturn,
} from 'nuqs';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
@@ -225,3 +232,58 @@ export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
INFRA_MONITORING_K8S_PARAMS_KEYS.STATUS_FILTER,
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
);
/**
* The list APIs reject the `no_data` sentinel (see IsFilterable in
* pkg/types/inframonitoringtypes), so it is never offered as a filter value.
*/
function withoutNoData<T extends string>(values: T[]): T[] {
return values.filter((value) => value !== 'no_data');
}
export const FILTERABLE_POD_STATUSES = withoutNoData(
Object.values(InframonitoringtypesPodStatusDTO),
);
export const FILTERABLE_NODE_CONDITIONS = withoutNoData(
Object.values(InframonitoringtypesNodeConditionDTO),
);
export const FILTERABLE_CONTAINER_STATUSES = withoutNoData(
Object.values(InframonitoringtypesContainerStatusDTO),
);
const EMPTY_FILTER: [] = [];
export const useInfraMonitoringPodStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesPodStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.POD_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_POD_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringNodeReadinessFilter = (): UseQueryStateReturn<
InframonitoringtypesNodeConditionDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_READINESS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_NODE_CONDITIONS))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringContainerStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesContainerStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CONTAINER_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_CONTAINER_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);

View File

@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator: string;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}

View File

@@ -9,8 +9,6 @@ export type OrderByFilterProps = {
isRawQuery?: boolean;
entityVersion?: string;
isNewQueryV2?: boolean;
/** Shown while no order is set, e.g. to name the order the server falls back to. */
placeholder?: string;
};
export type OrderByFilterValue = {

View File

@@ -15,7 +15,6 @@ export function OrderByFilter({
isRawQuery = false,
entityVersion,
isNewQueryV2 = false,
placeholder,
}: OrderByFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
@@ -88,7 +87,6 @@ export function OrderByFilter({
disabled={isMetricsDataSource && isDisabledSelect}
showArrow={false}
value={selectedValue}
placeholder={placeholder}
labelInValue
filterOption={false}
options={optionsData}

View 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;

View 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%;
}

View 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;

View File

@@ -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);
}

View File

@@ -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>

View 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;

View 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;

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
}

View 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;

View File

@@ -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,
};
}

View 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 };
}

View File

@@ -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]);
}

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