mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-12 13:10:41 +01:00
Compare commits
12 Commits
feat/scatt
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fe47d23a5 | ||
|
|
86ade5973e | ||
|
|
5d2e6d12e4 | ||
|
|
6b13794728 | ||
|
|
ba3ca5645e | ||
|
|
3d22976c72 | ||
|
|
059801d80d | ||
|
|
c44926b5f8 | ||
|
|
5f8fa52e7c | ||
|
|
cf3662a7e5 | ||
|
|
18527e62c2 | ||
|
|
0e2adf3329 |
@@ -58,8 +58,10 @@ console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
Screenshots are worth taking for `Default` in both themes
|
||||
(`&globals=theme:light`): text extraction does not catch an unstyled page.
|
||||
Screenshots are worth taking for `Default` in both themes: text extraction does
|
||||
not catch an unstyled page. Take them with
|
||||
[sbshot](https://github.com/SigNoz/sbshot), e.g.
|
||||
`sbshot capture http://localhost:6006 --title <story title> --theme dark,light`.
|
||||
|
||||
## Gates
|
||||
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
---
|
||||
name: storybook-visual-diff
|
||||
description: Screenshot a set of SigNoz Storybook stories, then pixel-diff two runs to see what a CSS or component change did, with the changes tinted over the new shot. Use when asked to take story screenshots, capture a visual baseline, compare before/after of a style change, or find which pages a change affects.
|
||||
---
|
||||
|
||||
# Storybook visual diff
|
||||
|
||||
Two scripts under `frontend/scripts`:
|
||||
|
||||
- `story-shots.mjs` — screenshots stories off a running Storybook dev server.
|
||||
- `story-shots-diff.mjs` — pixel-diffs two runs and paints what moved.
|
||||
|
||||
Output goes to `frontend/.story-shots/` (gitignored), one directory per run.
|
||||
|
||||
## 0. Settle what is being compared, first
|
||||
|
||||
A diff is only worth taking when the two runs straddle something. Run twice over
|
||||
the same tree and the answer is zero, or the noise floor: true, and useless.
|
||||
So before starting a server, pin down four things. Whatever the prompt already
|
||||
says, take it and do not ask again; ask only for what is genuinely missing, in
|
||||
**one** `AskUserQuestion` call.
|
||||
|
||||
| To settle | Ask | Options |
|
||||
| --- | --- | --- |
|
||||
| Job | "What should this run produce?" | shoot only · baseline for a change you are about to make · compare against a change already in the working tree · compare this branch against another (`main` by default, or one the user names) · compare two configurations of the same story (`--args`, clock, width) · noise floor (same tree twice) |
|
||||
| Scope | "Which stories?" | offer 2-3 concrete selections read off `index.json` (a page, a `--title` prefix, everything), never open-ended |
|
||||
| Themes | "Which themes?" | dark · dark + light |
|
||||
| Read-out | "How should the diff read?" | `green` (changed pixels over the after shot) · `green-parallel` (before \| after \| diff, side by side) · `red` · `red-parallel` · `none` (keep both runs, do not diff) |
|
||||
|
||||
Skip a row when the prompt answers it, and skip the whole call when the prompt
|
||||
answers all of it ("shoot the pods tooltips in both themes" needs no question).
|
||||
Skip Read-out too whenever the job is *shoot only*, and take `none` for what it
|
||||
says: shoot both sides, report both paths, run no comparison. When the prompt
|
||||
says nothing at all, ask; a silent guess here burns ~6 min per sweep on the
|
||||
wrong stories.
|
||||
|
||||
The job decides which loop below to run:
|
||||
|
||||
| Job | Loop |
|
||||
| --- | --- |
|
||||
| **shoot only** | §1, §2, stop. Report the paths. No diff, no second run. |
|
||||
| **baseline first** | the full loop, stopping after step 2 to hand the change back. The user makes it, then continue at step 4. |
|
||||
| **change already in the tree** | the tree *is* the after state. `git stash` (or check out the base commit) to shoot the before, restore, shoot the after. Confirm the working tree is clean enough to stash before touching it, and restore it even if a capture fails. |
|
||||
| **branch vs branch** | shoot the current branch, then `git switch <base>` in place (stash first if the tree is dirty), restart the dev server, shoot again, switch back and unstash. Restart matters: HMR does not survive a whole-branch swap cleanly. Get the tree back to where it started even if a capture fails. |
|
||||
| **noise floor** | two runs, same tree, diff. The number is the harness's floor, not a finding. |
|
||||
| **config vs config** | same tree, two runs that differ only in flags: `--args`, `--clock`, `--width`, `--theme`, `--motion`. Filenames stay identical, so the pairs line up and the caption names what changed. |
|
||||
|
||||
## The loop
|
||||
|
||||
1. Capture the baseline **before touching anything**.
|
||||
2. Capture it a second time and diff the two. That is the noise floor: anything
|
||||
it reports is what the harness cannot hold still, and no conclusion about the
|
||||
change may rest on those stories. Cheap on a handful of stories, ~6 min per
|
||||
32, so on a wide sweep run it over the two or three stories the change is
|
||||
aimed at instead of all of them.
|
||||
3. Make the change.
|
||||
4. Capture again into a third directory.
|
||||
5. Diff, then read the tinted shot of the largest movers to judge the change.
|
||||
|
||||
## 1. One dev server, on a free port
|
||||
|
||||
`storybook dev` keys its Vite dep cache off the config dir, so two servers on the
|
||||
same `-c` serve mismatched prebundles and every story dies with `Invalid hook
|
||||
call`. Check what is already up first — port 6006 is often another repo's
|
||||
Storybook, and its `index.json` then indexes the wrong stories:
|
||||
|
||||
```bash
|
||||
for port in 6006 6007; do
|
||||
curl -s -m 2 "http://localhost:$port/index.json" | head -c 60 && echo " <- $port"
|
||||
done
|
||||
```
|
||||
|
||||
Start the SigNoz one on a free port, from the repo's own binary so no package
|
||||
manager shim is in the way:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
nohup ./node_modules/.bin/storybook dev -p 6007 --no-open --quiet \
|
||||
> "${TMPDIR:-/tmp}/signoz-storybook.log" 2>&1 &
|
||||
```
|
||||
|
||||
It is ready when `curl -s localhost:6007/index.json` returns JSON whose
|
||||
`entries` hold SigNoz story ids.
|
||||
|
||||
## 2. Capture
|
||||
|
||||
Playwright is not a frontend dependency. The script finds it in `tests/e2e`
|
||||
(`pnpm -C tests/e2e install`, `@playwright/test` is enough) or in a global
|
||||
install, and launches Playwright's own chromium, falling back to an installed
|
||||
Chrome. Two escape hatches when that is not what a machine has:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_MODULE=/path/to/playwright # a different install
|
||||
export CHROME_PATH=/path/to/chrome # a specific browser binary
|
||||
```
|
||||
|
||||
Then pick the stories. `--list` prints the selection without shooting anything:
|
||||
|
||||
```bash
|
||||
# every tooltip story of every page
|
||||
node scripts/story-shots.mjs .story-shots/baseline \
|
||||
--port 6007 --title Pages/ --name tooltip --theme dark
|
||||
|
||||
# a handful of stories by id or by title/name substring, both themes
|
||||
node scripts/story-shots.mjs .story-shots/baseline \
|
||||
--port 6007 --stories pages-noz,dashboards/detail --theme dark,light
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--stories <match>` | id or `Title/Name` substring, repeatable or comma-separated. Omit for every story. |
|
||||
| `--title <prefix>` | only titles starting with the prefix (`Pages/`, `Components/`) |
|
||||
| `--name <match>` | only story names containing the match |
|
||||
| `--theme dark,light` | one pass per theme; omit for the story's own default (dark) |
|
||||
| `--args <k:v;k2:v2>` | arg overrides, Storybook's own `?args=` syntax, repeatable. A dotted value is dropped by Storybook itself, so map it to a slug inside the story's mocks |
|
||||
| `--port` | dev server port, or `$SB_PORT` |
|
||||
| `--width <px>` | the only fixed dimension, default 1680 |
|
||||
| `--height <px>` | shortest the viewport may be, default 1200 |
|
||||
| `--max-height <px>` | tallest it may grow to, default 8000 |
|
||||
| `--grow <what>` | `scrollers` (default) grows the viewport until the page's own scrollers fit, `document` only follows the document height, `none` keeps `--height` |
|
||||
| `--settle <ms>` | wait after the page goes quiet, default 1500 |
|
||||
| `--clock <iso\|live>` | wall clock the page reads, passed to the preview as `?storyClock`; `live` unfreezes it |
|
||||
| `--motion` | keep animations and transitions running (sets the `motion` global to `live`) |
|
||||
| `--ignore <selector>` | hide matching elements, on top of `[data-shot-ignore]` and `[data-chromatic="ignore"]` |
|
||||
| `--flat` | write `<out>/<id>.png`, no theme directory |
|
||||
| `--no-caption` | leave the caption band off the shots |
|
||||
| `--list` | print the matched stories and exit |
|
||||
|
||||
Files land at `<out>/<theme>/<story-id>.png`, next to a `shots.json` recording
|
||||
what each shot is (id, title, name, theme, `ok`/`busy`, the caption's height in
|
||||
rows) and how the run was configured (args, clock, width, height, grow, motion,
|
||||
settle, ignore). Keep the flags identical between the two runs or the diff pairs
|
||||
nothing.
|
||||
|
||||
Every shot carries the caption band described below, so a single screenshot says
|
||||
what it is on its own. `--no-caption` leaves it off, and so does a machine
|
||||
without ImageMagick (with a warning). The band never changes the shot's width
|
||||
(long text wraps rather than widening the canvas) and its height is recorded, so
|
||||
the diff crops it back off and never reports one caption against another. Two
|
||||
runs whose captions are different heights still diff to zero. A story that never held still for two
|
||||
identical frames is logged `busy` instead of `ok` — treat its diff as suspect.
|
||||
|
||||
Dark alone is enough while iterating on the harness; add `light` for the run you
|
||||
report.
|
||||
|
||||
## 3. Diff
|
||||
|
||||
```bash
|
||||
node scripts/story-shots-diff.mjs .story-shots/baseline .story-shots/capped .story-shots/diff
|
||||
```
|
||||
|
||||
Prints `<changed pixels> <theme>/<story>.png`, largest first, and writes one
|
||||
image per pair. Needs ImageMagick for PNG encode/decode (7's `magick`, or 6's
|
||||
`convert`/`identify`/`montage`); the comparison itself is in the script.
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `--mode green` | default. The after shot with the changed pixels painted over it, exactly the pixels that changed. What Chromatic shows. |
|
||||
| `--mode green-parallel` | `previous \| current \| diff` in one image, each tile labelled above it, on a gutter inverted from the theme. The diff tile is the `green` one, so the after shot stays readable underneath. |
|
||||
| `--mode red` | the after shot faded to 10%, changed pixels in red. A pixelmatch-style diff, easiest to read when the change is a thin edge. |
|
||||
| `--mode red-parallel` | the same three tiles, with the `red` diff. Best when the change is a thin edge that the unfaded shot would swallow. |
|
||||
| `--threshold <0..1>` | how far a pixel must move to count. Default 0.063, Chromatic's `diffThreshold`. |
|
||||
| `--include-aa` | count antialiasing changes too. Off by default, as in Chromatic. |
|
||||
| `--tint <#rrggbb>` | override the mode's colour. |
|
||||
| `--no-caption` | drop the caption band. |
|
||||
|
||||
### The caption
|
||||
|
||||
Both scripts stamp a band on top of what they write: `story-shots.mjs` on each
|
||||
shot, from the story and the run's own settings; `story-shots-diff.mjs` on each
|
||||
diff, read out of the two runs' `shots.json`. It carries the story's
|
||||
`Title/Name`, then its id, theme and `busy` flag, then the settings both runs
|
||||
shared, each reading `key:value`. Whatever the two runs did **differently** goes
|
||||
on the side it belongs to: under `previous` and `current` on the parallel tiles,
|
||||
on two lines of the band otherwise. So a pair that differs only in `--args` says
|
||||
so on its face, which is what makes several shots of one story tellable apart.
|
||||
|
||||
The shots' own bands are cropped off before comparing and before going into the
|
||||
tiles, so nothing in the output is a diff of a caption. Type size follows the
|
||||
image width, so it stays readable with the whole image viewed at fit-to-width;
|
||||
the heading is set in an installed sans and the detail lines in a mono, falling
|
||||
back to ImageMagick's default when neither is on the machine. Without a manifest
|
||||
the band falls back to the file path, and a directory of captioned shots whose
|
||||
`shots.json` is missing has nothing to crop by, so its captions do land in the
|
||||
diff. Keep `shots.json` next to the shots.
|
||||
|
||||
### How the comparison works
|
||||
|
||||
Chromatic's own capture and diff run server-side — `chromatic-cli` uploads a
|
||||
built Storybook and contains no capture or comparison code at all. What is public
|
||||
is the parameter contract, and the numbers in it say what the comparison is:
|
||||
`diffThreshold` defaults to `0.063` on a 0-1 scale, which is pixelmatch's
|
||||
`threshold`, and `diffIncludeAntiAliasing` defaults to false, which is
|
||||
pixelmatch's `includeAA: false`. So the script implements that comparison:
|
||||
|
||||
1. Both PNGs are read as raw RGBA through `magick … RGBA:-`.
|
||||
2. Per pixel, the squared YIQ distance between the two colours (weights
|
||||
`0.5053 / 0.299 / 0.1957`), compared against `35215 * threshold²` — 35215 is
|
||||
the largest distance two 8-bit colours can have. Chroma is included, so a
|
||||
colour swap at equal brightness still counts.
|
||||
3. A pixel over the threshold is dropped when it is only antialiasing: it is the
|
||||
darkest or lightest of its eight neighbours, and the other image has a pixel
|
||||
around there doing the same job. This is what keeps a subpixel glyph edge from
|
||||
reading as a change.
|
||||
4. What survives is painted at full opacity, one output pixel per changed input
|
||||
pixel. No dilation, no blobs — a one-pixel shift shows as a one-pixel line.
|
||||
|
||||
A pair whose shots are different sizes is compared over the overlap, and every
|
||||
row and column that exists in only one of them counts as changed.
|
||||
|
||||
Pairing is by `<theme>/<story-id>.png`, so a story that exists on only one side
|
||||
(new on the feature branch, renamed, retitled) has nothing to pair with and is
|
||||
skipped silently. On a branch-vs-branch run, compare the two runs' file lists
|
||||
before reading the numbers.
|
||||
|
||||
## What makes a shot reproducible
|
||||
|
||||
Most of it is in the preview, not in the script, so a Chromatic build in the
|
||||
cloud shoots the same page: `.storybook/preview-head.html` freezes the clock,
|
||||
and `settleForCapture` (the preview's `afterEach`, which runs after `play`)
|
||||
parks the animations and snaps the bottom-pinned lists. The script drives the
|
||||
rest:
|
||||
|
||||
- **Storybook's own render phase is the readiness signal.** It waits for
|
||||
`window.__STORYBOOK_PREVIEW__.storyRenders[].phase === 'finished'`, which is
|
||||
reached only after the loaders, the decorators and the story's `play` are done.
|
||||
A DOM check cannot see a `play` still running. (Storybook 10 spells the final
|
||||
phase `finished`, not `completed`.)
|
||||
- **Network quiescence, not `networkidle`.** react-query retries and msw keep
|
||||
requests going after load, and a few stories hang a request by design, so the
|
||||
wait is "no request for 600ms", capped at 15s.
|
||||
- **The clock is frozen** (`2026-06-15T12:00:00Z`), by the preview itself. Chart windows, `4 mins ago`
|
||||
labels and trial countdowns all derive from `now`; a live clock alone moved
|
||||
8000 pixels on the dashboards list and redrew every chart axis.
|
||||
- **Animations are parked on their last frame** by `html.sb-still`, a
|
||||
zero-length single iteration with `forwards` fill, plus `prefers-reduced-
|
||||
motion`. The Motion toolbar item (`still` by default) turns it off. An infinite
|
||||
spinner is otherwise caught at a random angle.
|
||||
- **`document.fonts.ready`**, because text reflows when a face lands late.
|
||||
- **Lists pinned to their bottom are snapped onto it**, once by the preview and
|
||||
again by the script after the page goes quiet. A virtuoso list settles a
|
||||
few pixels short of the end depending on the order its items were measured in.
|
||||
- **Two identical frames in a row**, because what a page is still waiting on is
|
||||
often not observable from outside it.
|
||||
- **`[data-shot-ignore]`, `[data-chromatic="ignore"]` and `--ignore <selector>`**
|
||||
hide a region that cannot be held still; Chromatic excludes the same attribute
|
||||
from its comparison.
|
||||
- **The width is the only fixed dimension.** Chromatic's `viewports` are widths;
|
||||
the height follows the page. `src/styles.scss` pins `html, body, #root` to
|
||||
`height: 100%; overflow: hidden`, so the document never outgrows the viewport
|
||||
and its height says nothing: what overflows are the shell's inner scrollers.
|
||||
`--grow scrollers`, the default, grows the viewport until the tallest in-flow
|
||||
scroller fits, so nothing is cut off and no scrollbar is left in the shot (the
|
||||
dashboards list goes to 2226px in one round). Popups are skipped — they are out
|
||||
of the flow, and a tall dropdown would otherwise drag the shot to a height
|
||||
nothing on the page needs. A page that sizes a panel in `vh` grows its own
|
||||
content as the viewport grows, so no height ever fits it and the rounds only
|
||||
chase — `.alert-chart-container` is `57vh`, which puts Create Alert's fixed
|
||||
point at 4344px with an empty band on top. Those pages are shot at `--height`
|
||||
with their own scrollbar, which is what they look like in a browser, and the
|
||||
log says `(viewport-sized content, stopped chasing Npx)`.
|
||||
|
||||
With all of that, 29 of the 32 page tooltip stories are byte-identical across
|
||||
runs. The three that are not, and why:
|
||||
|
||||
| Story | Residual | Cause |
|
||||
| --- | --- | --- |
|
||||
| `kubernetes-pods--tooltips-in-options-panel` | ~13k px | 24 tooltips held open in an overlapping cluster; they portal to `body` in mount order, and the drawer's own tooltips mount before or after the list's depending on when their data lands, so overlapping tooltips stack differently. Panel geometry itself is stable. |
|
||||
| `settings-role-editor--tooltips-in-json-editor` | ~2.5k px | monaco re-measures and lands one pixel off. |
|
||||
| `traces-trace-details--tooltips` | ~800 px | same class, one row of the waterfall. |
|
||||
|
||||
Each is bimodal — two stable arrangements — so the same number reappears run
|
||||
after run. Diff a story against itself before believing its number, and reach
|
||||
for `--ignore` when a region cannot be settled.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Zero pixels is a real answer.** A story whose tooltips are all short is
|
||||
unaffected by a tooltip rule; it is not a broken capture.
|
||||
- **The selector matters more than the rule.** A global rule on
|
||||
`[data-slot='…']` only reaches design-system components. antd's own tooltips
|
||||
(`.ant-tooltip-inner`, e.g. the Create Alert help popups) are untouched, which
|
||||
is why some stories show no diff at all.
|
||||
- **Global style overrides need `!important`.** `src/styles.scss` loads before
|
||||
the design system injects its CSS-module styles at runtime, so a plain rule on
|
||||
a `[data-slot='…']` element loses. A component-level `!important` of the same
|
||||
specificity still wins over it — `PanelStatusPopover.module.scss` keeps its own
|
||||
`max-width: 520px !important`.
|
||||
- **A fresh context per story** is why a full sweep takes ~6 min for 32 stories.
|
||||
Reusing one page loses the msw service worker re-registration race and stories
|
||||
start failing after a few navigations.
|
||||
- **Stories behind a hover, drawer or modal** only render what their `play`
|
||||
reaches. If a state is missing from the shot, the story needs the `play`, not
|
||||
the script.
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -296,4 +296,3 @@ go.mod @therealpandey
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
/.claude/skills/signoz-page-story/ @H4ad
|
||||
/.claude/skills/storybook-visual-diff/ @H4ad
|
||||
|
||||
51
.github/workflows/e2eci.yaml
vendored
51
.github/workflows/e2eci.yaml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
@@ -48,12 +49,33 @@ jobs:
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
# Images bring-up-stack would otherwise pull, at the tests/conftest.py
|
||||
# defaults. A stale tag only loses the head start.
|
||||
- name: pull-images
|
||||
id: pull-images
|
||||
background: true
|
||||
run: |
|
||||
printf '%s\n' \
|
||||
node:22-bookworm \
|
||||
golang:1.25-bookworm \
|
||||
python:3.13-slim \
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.9 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: python-install
|
||||
id: python-install
|
||||
background: true
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -62,19 +84,9 @@ jobs:
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: python-install
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: pnpm-install
|
||||
run: |
|
||||
cd tests/e2e && pnpm install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
# Restore-only: the cacheci workflow owns cache saves. Seeds the
|
||||
# BuildKit cache mounts so the in-test image build is incremental.
|
||||
- name: restore
|
||||
@@ -99,6 +111,19 @@ jobs:
|
||||
tar -xf /restored/pnpm-store.tar -C /pnpm/store
|
||||
EOF
|
||||
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
|
||||
- name: wait-stack-deps
|
||||
wait: [pull-images, python-install]
|
||||
# Started after the pulls so its download overlaps the CPU-bound
|
||||
# image build instead of competing for the network.
|
||||
- name: playwright-browsers
|
||||
id: playwright-browsers
|
||||
background: true
|
||||
run: |
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
@@ -106,10 +131,12 @@ jobs:
|
||||
--basetemp=./tmp/ \
|
||||
-vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
- name: wait-playwright-browsers
|
||||
wait: playwright-browsers
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }}
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
@@ -122,6 +149,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.project }}
|
||||
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -60,6 +60,7 @@ jobs:
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- promapiconformance
|
||||
- promote
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -231,5 +231,7 @@ pyrightconfig.json
|
||||
|
||||
# dev
|
||||
.dev/
|
||||
.deepsec/
|
||||
.deepsec.zip
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -3425,13 +3425,6 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4174,7 +4167,6 @@ 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'
|
||||
@@ -4190,7 +4182,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4204,7 +4195,6 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4290,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:
|
||||
@@ -4381,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:
|
||||
@@ -4564,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
|
||||
@@ -7869,6 +7756,8 @@ components:
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
context:
|
||||
type: string
|
||||
indexes:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesWrappedIndex'
|
||||
@@ -7877,6 +7766,12 @@ components:
|
||||
type: string
|
||||
promote:
|
||||
type: boolean
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- signal
|
||||
- context
|
||||
- path
|
||||
type: object
|
||||
PromotetypesWrappedIndex:
|
||||
properties:
|
||||
@@ -13545,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
|
||||
@@ -13820,6 +13611,156 @@ paths:
|
||||
tags:
|
||||
- preferences
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/promoted_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the promoted paths of every JSON column, each
|
||||
annotated with its signal and context. The signal, context, promoted and indexes
|
||||
query parameters filter the listing.
|
||||
operationId: ListPromotedPaths
|
||||
parameters:
|
||||
- in: query
|
||||
name: signal
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: context
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: promoted
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
- in: query
|
||||
name: indexes
|
||||
schema:
|
||||
nullable: true
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
- tokenizer:
|
||||
- logs-field:list
|
||||
- traces-field:list
|
||||
summary: List promoted paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint promotes paths of JSON columns to their promoted
|
||||
columns. Each path names its promotion target with its signal and context,
|
||||
e.g. traces/attribute.
|
||||
operationId: PromotePaths
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
logs_body:
|
||||
summary: 'Logs body: promote a path and index it'
|
||||
value:
|
||||
- context: body
|
||||
indexes:
|
||||
- fieldDataType: string
|
||||
granularity: 1
|
||||
type: ngrambf_v1(4, 1024, 2, 0)
|
||||
path: user.name
|
||||
promote: true
|
||||
signal: logs
|
||||
traces_attribute:
|
||||
summary: 'Traces attribute: promote a path'
|
||||
value:
|
||||
- context: attribute
|
||||
path: http.method
|
||||
promote: true
|
||||
signal: traces
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/PromotetypesPromotePath'
|
||||
nullable: true
|
||||
type: array
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
- tokenizer:
|
||||
- logs-field:update
|
||||
- traces-field:update
|
||||
summary: Promote paths
|
||||
tags:
|
||||
- promote
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/roles:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
4
frontend/.gitignore
vendored
4
frontend/.gitignore
vendored
@@ -34,5 +34,5 @@ e2e/test-plan/user-preferences/
|
||||
/storybook-static/
|
||||
debug-storybook.log
|
||||
|
||||
# Storybook screenshot sweeps (scripts/story-shots.mjs)
|
||||
/.story-shots/
|
||||
# Storybook screenshots (sbshot)
|
||||
/.sbshot/
|
||||
|
||||
@@ -338,6 +338,10 @@
|
||||
"name": "api/generated/services/dashboard",
|
||||
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
|
||||
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
|
||||
},
|
||||
{
|
||||
"name": "motion/react-client",
|
||||
"message": "[motion] motion/react-client is for React Server Components only. Use 'motion/react' instead — it provides the same exports and works correctly in client-side SPAs."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -578,17 +582,6 @@
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
|
||||
// algorithms that read worse when split up.
|
||||
"files": [
|
||||
"scripts/**"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"sonarjs/cognitive-complexity": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@ const mockAliases = [
|
||||
find: /^@signozhq\/ui\/tooltip$/,
|
||||
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
|
||||
},
|
||||
{
|
||||
// jest: not replaced, a test asserts on the toast it fires.
|
||||
find: /^@signozhq\/ui\/sonner$/,
|
||||
replacement: `${srcPath}/storybook/mocks/sonner.mock.tsx`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Chromatic modes: one snapshot per entry, per story. The globals in a mode are
|
||||
* Storybook's own, so `theme` is the toolbar's theme and the story renders the
|
||||
* way it does locally. The width matches `scripts/story-shots.mjs` (`--width`),
|
||||
* way it does locally. The width matches sbshot's default `--width`,
|
||||
* so a cloud snapshot and a local shot frame the same page.
|
||||
*/
|
||||
export const allModes = {
|
||||
|
||||
@@ -70,3 +70,49 @@
|
||||
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Storybook has no microphone. Chromium's recognizer asks for one and for
|
||||
// Google's speech service, and the `not-allowed` or `network` it reports makes
|
||||
// the assistant hide its mic for the rest of the tab. This one starts
|
||||
// listening at once and hears only what a story passes to `hear`.
|
||||
(() => {
|
||||
class StorySpeechRecognition extends EventTarget {
|
||||
static listening = null;
|
||||
lang = 'en-US';
|
||||
continuous = false;
|
||||
interimResults = false;
|
||||
onstart = null;
|
||||
onresult = null;
|
||||
onerror = null;
|
||||
onend = null;
|
||||
|
||||
start() {
|
||||
StorySpeechRecognition.listening = this;
|
||||
setTimeout(() => this.onstart?.());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (StorySpeechRecognition.listening === this) {
|
||||
StorySpeechRecognition.listening = null;
|
||||
}
|
||||
setTimeout(() => this.onend?.());
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/** An interim result: the words so far, still listening. */
|
||||
hear(transcript) {
|
||||
const result = Object.assign([{ transcript, confidence: 1 }], {
|
||||
isFinal: false,
|
||||
});
|
||||
this.onresult?.({ resultIndex: 0, results: [result] });
|
||||
}
|
||||
}
|
||||
|
||||
window.SpeechRecognition = StorySpeechRecognition;
|
||||
window.webkitSpeechRecognition = StorySpeechRecognition;
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -122,7 +122,21 @@ const preview: Preview = {
|
||||
['List', 'New', 'Edit'],
|
||||
],
|
||||
'Dashboards',
|
||||
['List', 'Detail', 'Panel Editor', 'Public'],
|
||||
[
|
||||
'List',
|
||||
'Detail',
|
||||
[
|
||||
'Overview',
|
||||
'Settings',
|
||||
'Variables',
|
||||
'New Variable',
|
||||
'Edit Variable',
|
||||
'Authz',
|
||||
],
|
||||
'Panel Editor',
|
||||
['Overview', 'Chart Options', 'Thresholds and Links'],
|
||||
'Public',
|
||||
],
|
||||
'Services',
|
||||
['List', 'Detail', 'Top Level Operations', 'Service Map'],
|
||||
'Logs',
|
||||
|
||||
@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
// The filter editor's ANTLR parser reports every syntax error through
|
||||
// `console.error` (`line 1:14 missing ...`), so each partial expression
|
||||
// typed into it logs one; the editor shows the same errors on screen.
|
||||
/^line \d+:\d+ /,
|
||||
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
|
||||
// already reports with its URL and the time its request left.
|
||||
/^Failed to load resource: the server responded with a status of /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push({
|
||||
at: Date.now(),
|
||||
// The browser's clock, when the request left: a busy runner receives
|
||||
// the outgoing story's responses after the next story has started.
|
||||
at: response.request().timing().startTime,
|
||||
text: `[response] ${response.status()} ${response.url()}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* The caption band both story-shots.mjs and story-shots-diff.mjs stamp on their
|
||||
* output, and the ImageMagick plumbing under it. A shot records the band's
|
||||
* height in `shots.json` so the diff can crop it back off before comparing:
|
||||
* otherwise two runs whose captions differ would report the caption as a change.
|
||||
*/
|
||||
export const CONFIG_KEYS = [
|
||||
'args',
|
||||
'clock',
|
||||
'width',
|
||||
'height',
|
||||
'grow',
|
||||
'motion',
|
||||
'settle',
|
||||
'ignore',
|
||||
];
|
||||
|
||||
let tools;
|
||||
|
||||
const detect = () =>
|
||||
(tools ??= {
|
||||
seven: spawnSync('magick', ['-version']).status === 0,
|
||||
six: spawnSync('convert', ['-version']).status === 0,
|
||||
});
|
||||
|
||||
export const hasMagick = () => {
|
||||
const { seven, six } = detect();
|
||||
return seven || six;
|
||||
};
|
||||
|
||||
export const requireMagick = () => {
|
||||
if (hasMagick()) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
'ImageMagick not found. Install it (brew install imagemagick, apt install imagemagick).',
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
export const magick = (args, input) => {
|
||||
// ImageMagick 6 has no `magick`: its tools are separate binaries.
|
||||
const [command, ...rest] = detect().seven
|
||||
? ['magick', ...args]
|
||||
: ['identify', 'montage'].includes(args[0])
|
||||
? args
|
||||
: ['convert', ...args];
|
||||
const result = spawnSync(command, rest, {
|
||||
input,
|
||||
maxBuffer: 1024 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${rest.join(' ')}: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
/**
|
||||
* ImageMagick's built-in default is a serif that reads as a book, not as a
|
||||
* screenshot label, so the band asks for what is installed: a sans for the
|
||||
* heading, a mono for the lines that carry ids, args and numbers. An
|
||||
* unrecognised name is fatal to `convert`, hence the check against the list it
|
||||
* reports; a machine with none of them keeps the default.
|
||||
*/
|
||||
const FONTS = {
|
||||
heading: [
|
||||
'Helvetica-Bold',
|
||||
'DejaVu-Sans-Bold',
|
||||
'Liberation-Sans-Bold',
|
||||
'Arial-Bold',
|
||||
'Noto-Sans-Bold',
|
||||
'DejaVu-Sans',
|
||||
'Liberation-Sans',
|
||||
],
|
||||
body: [
|
||||
'Menlo',
|
||||
'DejaVu-Sans-Mono',
|
||||
'Liberation-Mono',
|
||||
'JetBrainsMono-NF-Regular',
|
||||
'Courier',
|
||||
],
|
||||
};
|
||||
|
||||
let installed;
|
||||
|
||||
const fontArgs = (role) => {
|
||||
installed ??= new Set(
|
||||
[
|
||||
...magick(['-list', 'font'])
|
||||
.toString()
|
||||
.matchAll(/^\s*Font:\s*(\S+)/gm),
|
||||
].map(([, name]) => name),
|
||||
);
|
||||
const font = FONTS[role].find((name) => installed.has(name));
|
||||
return font ? ['-font', font] : [];
|
||||
};
|
||||
|
||||
/** Readable at fit-to-width, whatever the image is. */
|
||||
export const pointsize = (width) =>
|
||||
Math.min(Math.max(Math.round(width / 45), 24), 140);
|
||||
|
||||
// `label:` expands ImageMagick's own escapes and reads a file when the text
|
||||
// starts with @, so story names and arg values go through neither.
|
||||
export const bodyFont = () => fontArgs('body');
|
||||
|
||||
export const literal = (text) => text.replaceAll('%', '%%').replace(/^@/, ' @');
|
||||
|
||||
/** The gutter is the opposite of the theme, so the band keeps an edge. */
|
||||
export const palette = (theme) =>
|
||||
theme === 'light'
|
||||
? { background: '#101014', foreground: '#f4f4f5' }
|
||||
: { background: '#f4f4f5', foreground: '#101014' };
|
||||
|
||||
export const settingsLine = (config, keys = CONFIG_KEYS) =>
|
||||
keys
|
||||
.filter((key) => config?.[key])
|
||||
.map((key) => `${key}:${config[key]}`)
|
||||
.join(' ');
|
||||
|
||||
const heightOf = (file) => Number(magick(['identify', '-format', '%h', file]));
|
||||
|
||||
/**
|
||||
* Writes `from` to `to` with `lines` above it, and returns how many rows that
|
||||
* added — which is what a reader has to crop off to get the original back, so
|
||||
* the band must never change the width. Each line is a `caption:` at the
|
||||
* image's own width, wrapping instead of widening the canvas: a run whose
|
||||
* caption is longer must still produce a shot the next run's shot pairs with.
|
||||
* Type size follows the width, since a three-tile montage of 1680px shots is
|
||||
* over 5000px wide and is read at fit-to-width.
|
||||
*/
|
||||
export const stamp = ({ lines, from, to, theme }) => {
|
||||
const { background, foreground } = palette(theme);
|
||||
const width = Number(magick(['identify', '-format', '%w', from]));
|
||||
const heading = pointsize(width);
|
||||
const before = heightOf(from);
|
||||
const spacer = [
|
||||
'-size',
|
||||
`${width}x${Math.round(heading * 0.4)}`,
|
||||
`xc:${background}`,
|
||||
];
|
||||
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
'-gravity',
|
||||
'center',
|
||||
...spacer,
|
||||
...lines.flatMap((line, index) => [
|
||||
...fontArgs(index ? 'body' : 'heading'),
|
||||
'-size',
|
||||
`${width}x`,
|
||||
'-pointsize',
|
||||
String(index ? Math.round(heading * 0.62) : heading),
|
||||
`caption:${literal(line)}`,
|
||||
]),
|
||||
...spacer,
|
||||
from,
|
||||
'-append',
|
||||
to,
|
||||
]);
|
||||
|
||||
return heightOf(to) - before;
|
||||
};
|
||||
@@ -1,460 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { parseArgs } from 'node:util';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
bodyFont,
|
||||
CONFIG_KEYS,
|
||||
literal,
|
||||
magick,
|
||||
palette,
|
||||
pointsize,
|
||||
requireMagick,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
|
||||
/**
|
||||
* Pairs the PNGs of two story-shots.mjs runs by relative path and reports what
|
||||
* moved, per pair, largest first.
|
||||
*
|
||||
* The comparison is Chromatic's: a pixel counts as changed when its YIQ
|
||||
* distance from the baseline pixel is over `threshold` of the largest distance
|
||||
* two colours can have, and pixels that are only antialiasing around an
|
||||
* otherwise identical edge do not count. `threshold` is their `diffThreshold`
|
||||
* and its default is theirs too.
|
||||
*/
|
||||
const MAX_YIQ_DELTA = 35_215;
|
||||
|
||||
const { values: opts, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
mode: { type: 'string', default: 'green' },
|
||||
threshold: { type: 'string', default: '0.063' },
|
||||
'include-aa': { type: 'boolean', default: false },
|
||||
tint: { type: 'string', default: '' },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const [baseDir, afterDir, outArg] = positionals;
|
||||
const MODES = new Set(['green', 'green-parallel', 'red', 'red-parallel']);
|
||||
|
||||
if (opts.help || !baseDir || !afterDir || !MODES.has(opts.mode)) {
|
||||
console.log(`usage: node scripts/story-shots-diff.mjs <baseline-dir> <after-dir> [diff-dir]
|
||||
|
||||
--mode green the after shot, changed pixels painted over it (default)
|
||||
--mode green-parallel previous | current | green diff, side by side and labelled
|
||||
--mode red the after shot faded out, changed pixels painted red
|
||||
--mode red-parallel previous | current | red diff, side by side and labelled
|
||||
--threshold <0..1> YIQ distance a pixel must move to count (default 0.063)
|
||||
--include-aa count antialiasing changes too (default: ignore them)
|
||||
--tint <#rrggbb> override the mode's highlight colour
|
||||
--no-caption do not stamp the story and the run settings on top
|
||||
|
||||
Prints "<changed pixels> <relative path>", largest first. Needs ImageMagick.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const outDir = outArg ?? path.join(path.dirname(baseDir), 'diff');
|
||||
const threshold = Number(opts.threshold);
|
||||
const maxDelta = MAX_YIQ_DELTA * threshold * threshold;
|
||||
const highlight = hexToRgb(
|
||||
opts.tint || (opts.mode.startsWith('green') ? '#00e05a' : '#ff003a'),
|
||||
);
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const value = Number.parseInt(hex.replace('#', ''), 16);
|
||||
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
}
|
||||
|
||||
requireMagick();
|
||||
|
||||
/**
|
||||
* `top` rows are dropped: story-shots.mjs stamps a caption on its shots and
|
||||
* records how tall it is, and a caption is not part of what the two runs are
|
||||
* being compared on.
|
||||
*/
|
||||
const readRgba = (file, top = 0) => {
|
||||
const [width, height] = magick(['identify', '-format', '%w %h', file])
|
||||
.toString()
|
||||
.split(' ')
|
||||
.map(Number);
|
||||
const data = magick([file, '-depth', '8', 'RGBA:-']);
|
||||
return top > 0 && top < height
|
||||
? { width, height: height - top, data: data.subarray(top * width * 4) }
|
||||
: { width, height, data };
|
||||
};
|
||||
|
||||
const writeRgba = ({ width, height, data }, file) =>
|
||||
writeFile(
|
||||
file,
|
||||
magick(
|
||||
['-depth', '8', '-size', `${width}x${height}`, 'RGBA:-', 'png:-'],
|
||||
data,
|
||||
),
|
||||
);
|
||||
|
||||
/* The pixelmatch colour maths, which is what Chromatic's threshold is scaled to. */
|
||||
const y = (r, g, b) => r * 0.29889531 + g * 0.58662247 + b * 0.11448223;
|
||||
const i = (r, g, b) => r * 0.59597799 - g * 0.2741761 - b * 0.32180189;
|
||||
const q = (r, g, b) => r * 0.21147017 - g * 0.52261711 + b * 0.31114694;
|
||||
|
||||
/** Squared YIQ distance, signed by which pixel is brighter. */
|
||||
const colorDelta = (a, b, posA, posB, yOnly = false) => {
|
||||
let r1 = a[posA];
|
||||
let g1 = a[posA + 1];
|
||||
let b1 = a[posA + 2];
|
||||
const a1 = a[posA + 3];
|
||||
let r2 = b[posB];
|
||||
let g2 = b[posB + 1];
|
||||
let b2 = b[posB + 2];
|
||||
const a2 = b[posB + 3];
|
||||
|
||||
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Anything translucent is composited over the same mid grey in both images,
|
||||
// so a difference in alpha alone still registers.
|
||||
if (a1 < 255) {
|
||||
const alpha = a1 / 255;
|
||||
r1 = r1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
g1 = g1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
b1 = b1 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
}
|
||||
if (a2 < 255) {
|
||||
const alpha = a2 / 255;
|
||||
r2 = r2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
g2 = g2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
b2 = b2 * alpha + 255 * (1 - alpha) * 0.5;
|
||||
}
|
||||
|
||||
const deltaY = y(r1, g1, b1) - y(r2, g2, b2);
|
||||
if (yOnly) {
|
||||
return deltaY;
|
||||
}
|
||||
|
||||
const deltaI = i(r1, g1, b1) - i(r2, g2, b2);
|
||||
const deltaQ = q(r1, g1, b1) - q(r2, g2, b2);
|
||||
return (
|
||||
0.5053 * deltaY * deltaY + 0.299 * deltaI * deltaI + 0.1957 * deltaQ * deltaQ
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the pixel sits on an edge that is drawn one subpixel over rather
|
||||
* than moved: it is the darkest or lightest of its neighbours in one image, and
|
||||
* the other image has a pixel around there doing the same job.
|
||||
*/
|
||||
const antialiased = (a, x1, y1, width, height, b) => {
|
||||
const x0 = Math.max(x1 - 1, 0);
|
||||
const y0 = Math.max(y1 - 1, 0);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
const pos = (y1 * width + x1) * 4;
|
||||
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
||||
let min = 0;
|
||||
let max = 0;
|
||||
let minX = 0;
|
||||
let minY = 0;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
|
||||
for (let x = x0; x <= x2; x += 1) {
|
||||
for (let yy = y0; yy <= y2; yy += 1) {
|
||||
if (x === x1 && yy === y1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = colorDelta(a, a, pos, (yy * width + x) * 4, true);
|
||||
if (delta === 0) {
|
||||
zeroes += 1;
|
||||
if (zeroes > 2) {
|
||||
return false;
|
||||
}
|
||||
} else if (delta < min) {
|
||||
min = delta;
|
||||
minX = x;
|
||||
minY = yy;
|
||||
} else if (delta > max) {
|
||||
max = delta;
|
||||
maxX = x;
|
||||
maxY = yy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (min === 0 || max === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(hasManySiblings(a, minX, minY, width, height) &&
|
||||
hasManySiblings(b, minX, minY, width, height)) ||
|
||||
(hasManySiblings(a, maxX, maxY, width, height) &&
|
||||
hasManySiblings(b, maxX, maxY, width, height))
|
||||
);
|
||||
};
|
||||
|
||||
/** Whether the pixel has at least three identical neighbours. */
|
||||
const hasManySiblings = (img, x1, y1, width, height) => {
|
||||
const x0 = Math.max(x1 - 1, 0);
|
||||
const y0 = Math.max(y1 - 1, 0);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
const pos = (y1 * width + x1) * 4;
|
||||
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
||||
|
||||
for (let x = x0; x <= x2; x += 1) {
|
||||
for (let yy = y0; yy <= y2; yy += 1) {
|
||||
if (x === x1 && yy === y1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const other = (yy * width + x) * 4;
|
||||
if (
|
||||
img[pos] === img[other] &&
|
||||
img[pos + 1] === img[other + 1] &&
|
||||
img[pos + 2] === img[other + 2] &&
|
||||
img[pos + 3] === img[other + 3]
|
||||
) {
|
||||
zeroes += 1;
|
||||
if (zeroes > 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The changed pixels of the pair, painted over the after shot. The `red` modes
|
||||
* fade the shot out first, the way a pixelmatch diff reads; the `green` ones
|
||||
* leave it alone, the way Chromatic's does.
|
||||
*/
|
||||
const diffPair = (base, after, mode) => {
|
||||
const width = Math.min(base.width, after.width);
|
||||
const height = Math.min(base.height, after.height);
|
||||
const out = Buffer.from(after.data);
|
||||
const fade = !mode.startsWith('green');
|
||||
let changed = 0;
|
||||
|
||||
if (fade) {
|
||||
for (let pos = 0; pos < out.length; pos += 4) {
|
||||
const grey = y(out[pos], out[pos + 1], out[pos + 2]);
|
||||
const value = 255 + (grey - 255) * 0.1;
|
||||
out[pos] = value;
|
||||
out[pos + 1] = value;
|
||||
out[pos + 2] = value;
|
||||
out[pos + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let column = 0; column < width; column += 1) {
|
||||
const basePos = (row * base.width + column) * 4;
|
||||
const afterPos = (row * after.width + column) * 4;
|
||||
const delta = colorDelta(base.data, after.data, basePos, afterPos);
|
||||
if (Math.abs(delta) <= maxDelta) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!opts['include-aa'] &&
|
||||
(antialiased(base.data, column, row, base.width, base.height, after.data) ||
|
||||
antialiased(after.data, column, row, after.width, after.height, base.data))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changed += 1;
|
||||
out[afterPos] = highlight[0];
|
||||
out[afterPos + 1] = highlight[1];
|
||||
out[afterPos + 2] = highlight[2];
|
||||
out[afterPos + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// A shot that grew or shrank has no counterpart for the extra rows and
|
||||
// columns, so all of them are a change.
|
||||
const extra =
|
||||
after.width * after.height -
|
||||
width * height +
|
||||
(base.width * base.height - width * height);
|
||||
|
||||
return {
|
||||
data: out,
|
||||
width: after.width,
|
||||
height: after.height,
|
||||
changed: changed + extra,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What each run was and how it was configured, from the `shots.json`
|
||||
* story-shots.mjs leaves beside its output. A run shot before that existed, or
|
||||
* a directory assembled by hand, simply gets no caption.
|
||||
*/
|
||||
const manifest = async (dir) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(dir, 'shots.json'), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [baseRun, afterRun] = await Promise.all([
|
||||
manifest(baseDir),
|
||||
manifest(afterDir),
|
||||
]);
|
||||
|
||||
/** The settings the two runs disagree on: what a difference in the shots may be. */
|
||||
const changedKeys = CONFIG_KEYS.filter(
|
||||
(key) => (baseRun?.config?.[key] ?? '') !== (afterRun?.config?.[key] ?? ''),
|
||||
);
|
||||
|
||||
const settings = (run, keys) => settingsLine(run?.config, keys);
|
||||
|
||||
const shotOf = (run, rel) =>
|
||||
run?.shots?.find((shot) => shot.file === rel.split(path.sep).join('/'));
|
||||
|
||||
const captionOf = (run, rel) => shotOf(run, rel)?.caption ?? 0;
|
||||
|
||||
/** ImageMagick's inline crop, so a tile shows the shot without its caption. */
|
||||
const withoutCaption = (file, { width, height }, top) =>
|
||||
top > 0 ? `${file}[${width}x${height}+0+${top}]` : file;
|
||||
|
||||
/** Story, then the settings both runs shared. One line each, widest font first. */
|
||||
const header = (rel) => {
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const shared = settings(
|
||||
afterRun,
|
||||
CONFIG_KEYS.filter((key) => !changedKeys.includes(key)),
|
||||
);
|
||||
return [
|
||||
shot ? `${shot.title}/${shot.name}` : rel.replace(/\.png$/, ''),
|
||||
[shot?.id ?? '', shot?.theme ?? '', shot?.status === 'busy' ? '(busy)' : '']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
shared,
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
/** A tile's own line: which side it is, and where its run differed. */
|
||||
const sideLabel = (side, run) =>
|
||||
[side, settings(run, changedKeys)].filter(Boolean).join(' ');
|
||||
|
||||
const captionLines = (rel, lines) =>
|
||||
opts['no-caption'] ? [] : [...header(rel), ...lines].filter(Boolean);
|
||||
|
||||
const pngs = async (dir, prefix = '') => {
|
||||
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const rel = path.join(prefix, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await pngs(dir, rel)));
|
||||
} else if (entry.name.endsWith('.png')) {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const results = [];
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const rel of (await pngs(baseDir)).sort()) {
|
||||
const afterFile = path.join(afterDir, rel);
|
||||
const base = readRgba(path.join(baseDir, rel), captionOf(baseRun, rel));
|
||||
let after;
|
||||
try {
|
||||
after = readRgba(afterFile, captionOf(afterRun, rel));
|
||||
} catch {
|
||||
console.error(`missing in after: ${rel}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(path.join(outDir, path.dirname(rel)), { recursive: true });
|
||||
const diff = diffPair(base, after, opts.mode);
|
||||
const target = path.join(outDir, rel);
|
||||
const parallel = opts.mode.endsWith('-parallel');
|
||||
// With no tiles to label, a run's own settings go in the caption instead.
|
||||
const caption = captionLines(
|
||||
rel,
|
||||
parallel || !changedKeys.length
|
||||
? []
|
||||
: [sideLabel('previous', baseRun), sideLabel('current', afterRun)],
|
||||
);
|
||||
const diffFile = path.join(os.tmpdir(), `story-shots-${process.pid}.png`);
|
||||
const body = path.join(os.tmpdir(), `story-shots-${process.pid}-body.png`);
|
||||
|
||||
// The gutter is the opposite of the theme's own background, so the tiles and
|
||||
// the caption keep an edge instead of bleeding into it.
|
||||
const shot = shotOf(afterRun, rel) ?? shotOf(baseRun, rel);
|
||||
const theme = shot?.theme ?? rel.split(path.sep)[0];
|
||||
const { background, foreground } = palette(theme);
|
||||
|
||||
if (parallel) {
|
||||
await writeRgba(diff, diffFile);
|
||||
const tile = (label, file) => [
|
||||
'(',
|
||||
`label:${literal(label)}`,
|
||||
file,
|
||||
'-gravity',
|
||||
'center',
|
||||
'-append',
|
||||
'-bordercolor',
|
||||
background,
|
||||
'-border',
|
||||
'12',
|
||||
')',
|
||||
];
|
||||
magick([
|
||||
'-background',
|
||||
background,
|
||||
'-fill',
|
||||
foreground,
|
||||
...bodyFont(),
|
||||
'-pointsize',
|
||||
// The tiles end up side by side, so they are read at the montage's width.
|
||||
String(Math.round(pointsize(after.width * 3) * 0.62)),
|
||||
...tile(
|
||||
sideLabel('previous', baseRun),
|
||||
withoutCaption(path.join(baseDir, rel), base, captionOf(baseRun, rel)),
|
||||
),
|
||||
...tile(
|
||||
sideLabel('current', afterRun),
|
||||
withoutCaption(afterFile, after, captionOf(afterRun, rel)),
|
||||
),
|
||||
...tile('diff', diffFile),
|
||||
'-gravity',
|
||||
'north',
|
||||
'+append',
|
||||
caption.length ? body : target,
|
||||
]);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
} else {
|
||||
await writeRgba(diff, caption.length ? body : target);
|
||||
if (caption.length) {
|
||||
stamp({ lines: caption, from: body, to: target, theme });
|
||||
}
|
||||
}
|
||||
|
||||
results.push([diff.changed, rel]);
|
||||
}
|
||||
|
||||
results
|
||||
.sort((a, b) => b[0] - a[0])
|
||||
.forEach(([changed, rel]) =>
|
||||
console.log(`${String(changed).padStart(10)} ${rel}`),
|
||||
);
|
||||
|
||||
console.error(`diffs in ${outDir}`);
|
||||
@@ -1,514 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
import {
|
||||
CONFIG_KEYS,
|
||||
hasMagick,
|
||||
settingsLine,
|
||||
stamp,
|
||||
} from './story-shots-caption.mjs';
|
||||
|
||||
/**
|
||||
* The wall clock every shot is taken at, passed to the preview as `storyClock`.
|
||||
* `.storybook/preview-head.html` freezes the same instant by itself, so a
|
||||
* Chromatic build reads the clock this run does.
|
||||
*/
|
||||
const FROZEN_CLOCK = '2026-06-15T12:00:00.000Z';
|
||||
|
||||
const { values: opts, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
out: { type: 'string', short: 'o' },
|
||||
stories: { type: 'string', multiple: true, default: [] },
|
||||
title: { type: 'string', default: '' },
|
||||
name: { type: 'string', default: '' },
|
||||
theme: { type: 'string', multiple: true, default: [] },
|
||||
args: { type: 'string', multiple: true, default: [] },
|
||||
port: { type: 'string', default: process.env.SB_PORT ?? '6006' },
|
||||
width: { type: 'string', default: '1680' },
|
||||
height: { type: 'string', default: '1200' },
|
||||
'max-height': { type: 'string', default: '8000' },
|
||||
grow: { type: 'string', default: 'scrollers' },
|
||||
settle: { type: 'string', default: '1500' },
|
||||
clock: { type: 'string', default: FROZEN_CLOCK },
|
||||
motion: { type: 'boolean', default: false },
|
||||
ignore: { type: 'string', multiple: true, default: [] },
|
||||
flat: { type: 'boolean', default: false },
|
||||
'no-caption': { type: 'boolean', default: false },
|
||||
list: { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const outDir = opts.out ?? positionals[0];
|
||||
const themes = opts.theme.flatMap((value) => value.split(',')).filter(Boolean);
|
||||
const storyArgs = opts.args.filter(Boolean).join(';');
|
||||
|
||||
if (opts.help || (!outDir && !opts.list)) {
|
||||
console.log(`usage: node scripts/story-shots.mjs <out-dir> [options]
|
||||
|
||||
--stories <match> only stories whose id or title/name path contains <match>
|
||||
(repeatable, comma-separated; default: every story)
|
||||
--title <prefix> only stories whose title starts with <prefix>
|
||||
--name <match> only stories whose name contains <match>
|
||||
--theme <themes> themes to shoot, e.g. dark,light (default: story default)
|
||||
--args <k:v;k2:v2> arg overrides, storybook's own ?args= syntax (repeatable).
|
||||
A value containing a dot is dropped by storybook itself
|
||||
--port <port> storybook dev server port (default 6006, or $SB_PORT)
|
||||
--width <px> viewport width, the only fixed dimension (default 1680)
|
||||
--height <px> shortest the viewport may be (default 1200)
|
||||
--max-height <px> tallest the viewport may grow to (default 8000)
|
||||
--grow <what> scrollers (default) grows the viewport until the page's
|
||||
own scrollers fit, document only follows the document
|
||||
height (a no-op on any page with the app shell), none
|
||||
keeps --height
|
||||
--settle <ms> wait after the page goes quiet (default 1500)
|
||||
--clock <iso|live> wall clock the page reads (default ${FROZEN_CLOCK})
|
||||
--motion keep animations and transitions running
|
||||
--ignore <selector> hide matching elements, on top of [data-shot-ignore]
|
||||
--flat write <out>/<id>.png instead of <out>/<theme>/<id>.png
|
||||
--no-caption do not stamp the story and the run settings on the shot
|
||||
--list print the matched stories and exit
|
||||
|
||||
Screenshots land in <out-dir>/<theme>/<story-id>.png, alongside a shots.json
|
||||
recording what each shot is, how the run was configured, and how tall the
|
||||
caption on it is. story-shots-diff.mjs reads that to crop the caption off before
|
||||
comparing, so two runs never diff their own captions.
|
||||
|
||||
Captioning needs ImageMagick; without it the shots are written bare.
|
||||
|
||||
Playwright is looked up in tests/e2e, then in the global install; override with
|
||||
PLAYWRIGHT_MODULE. The browser is playwright's own chromium, else an installed
|
||||
Chrome; override with CHROME_PATH.`);
|
||||
process.exit(opts.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const base = `http://localhost:${opts.port}`;
|
||||
|
||||
// `index.json` carries raw control characters from story jsdoc, so it is read as
|
||||
// text rather than piped through anything that revalidates it.
|
||||
const index = JSON.parse(await (await fetch(`${base}/index.json`)).text());
|
||||
|
||||
const matches = opts.stories
|
||||
.flatMap((value) => value.split(','))
|
||||
.filter(Boolean);
|
||||
|
||||
const stories = Object.values(index.entries)
|
||||
.filter((entry) => {
|
||||
if (entry.type !== 'story') {
|
||||
return false;
|
||||
}
|
||||
if (opts.title && !entry.title.startsWith(opts.title)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
opts.name &&
|
||||
!entry.name.toLowerCase().includes(opts.name.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!matches.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${entry.id} ${entry.title}/${entry.name}`.toLowerCase();
|
||||
return matches.some((match) => haystack.includes(match.toLowerCase()));
|
||||
})
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
if (opts.list) {
|
||||
stories.forEach((story) =>
|
||||
console.log(`${story.id}\t${story.title}/${story.name}`),
|
||||
);
|
||||
console.log(`${stories.length} stories`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!stories.length) {
|
||||
console.error('no story matched');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ignoreSelectors = opts.ignore
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (opts.clock !== 'live' && Number.isNaN(Date.parse(opts.clock))) {
|
||||
console.error(`--clock: not a date: ${opts.clock}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[data-shot-ignore]` and `--ignore` hide what cannot be settled, the local
|
||||
* half of Chromatic's `data-chromatic="ignore"`. Everything else the shot needs
|
||||
* held still - the frozen clock, the parked animations, the lists snapped onto
|
||||
* their bottom - is done by the preview itself, so a Chromatic build and a shot
|
||||
* from here see the same page.
|
||||
*/
|
||||
const ignoreCss = (
|
||||
ignore,
|
||||
) => `[data-shot-ignore], [data-chromatic='ignore']${ignore
|
||||
.map((selector) => `, ${selector}`)
|
||||
.join('')} {
|
||||
visibility: hidden !important;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Playwright is not a frontend dependency: it lives in `tests/e2e`, or globally,
|
||||
* or wherever `$PLAYWRIGHT_MODULE` points. `@playwright/test` re-exports
|
||||
* `chromium`, so an e2e install alone is enough.
|
||||
*/
|
||||
const resolvePlaywright = () => {
|
||||
const specifiers = process.env.PLAYWRIGHT_MODULE
|
||||
? [process.env.PLAYWRIGHT_MODULE]
|
||||
: ['playwright', '@playwright/test'];
|
||||
|
||||
const find = (roots) => {
|
||||
for (const specifier of specifiers) {
|
||||
for (const root of roots) {
|
||||
try {
|
||||
return createRequire(path.join(root, '-')).resolve(specifier);
|
||||
} catch {
|
||||
/* next candidate */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const local = find([
|
||||
import.meta.dirname,
|
||||
path.resolve(import.meta.dirname, '../../tests/e2e'),
|
||||
]);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
|
||||
// `npm root -g` prints the global node_modules; resolution starts a level up.
|
||||
const globalRoot = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
|
||||
const global =
|
||||
globalRoot.status === 0 && find([path.dirname(globalRoot.stdout.trim())]);
|
||||
if (global) {
|
||||
return global;
|
||||
}
|
||||
|
||||
console.error(
|
||||
'playwright not found. Install it (pnpm -C tests/e2e install, or npm i -g playwright) or set PLAYWRIGHT_MODULE.',
|
||||
);
|
||||
return process.exit(1);
|
||||
};
|
||||
|
||||
const pwModule = await import(pathToFileURL(resolvePlaywright()).href);
|
||||
const pw = pwModule.chromium ? pwModule : pwModule.default;
|
||||
|
||||
console.log(
|
||||
`${stories.length} stories x ${themes.length || 1} theme(s) -> ${outDir}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* A playwright install carries no browser of its own, and the revision it wants
|
||||
* is often not the one that was downloaded, so an installed Chrome is the
|
||||
* fallback before giving up.
|
||||
*/
|
||||
const launch = async () => {
|
||||
if (process.env.CHROME_PATH) {
|
||||
return pw.chromium.launch({ executablePath: process.env.CHROME_PATH });
|
||||
}
|
||||
try {
|
||||
return await pw.chromium.launch();
|
||||
} catch (error) {
|
||||
try {
|
||||
return await pw.chromium.launch({ channel: 'chrome' });
|
||||
} catch {
|
||||
console.error(
|
||||
`${error.message.split('\n')[0]}\nRun 'playwright install chromium' or set CHROME_PATH to a browser binary.`,
|
||||
);
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await launch();
|
||||
|
||||
const failures = [];
|
||||
const shots = [];
|
||||
|
||||
const runConfig = {
|
||||
args: storyArgs,
|
||||
clock: opts.clock,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
grow: opts.grow,
|
||||
motion: opts.motion ? 'live' : 'still',
|
||||
settle: opts.settle,
|
||||
ignore: ignoreSelectors.join(', '),
|
||||
};
|
||||
|
||||
const captioning = !opts['no-caption'] && hasMagick();
|
||||
|
||||
if (!opts['no-caption'] && !captioning) {
|
||||
console.error('ImageMagick not found: shots are written without a caption.');
|
||||
}
|
||||
|
||||
const configLine = settingsLine(runConfig, CONFIG_KEYS);
|
||||
|
||||
for (const theme of themes.length ? themes : [null]) {
|
||||
const dir = opts.flat ? outDir : path.join(outDir, theme ?? 'default');
|
||||
await mkdir(dir, { recursive: true });
|
||||
if (theme) {
|
||||
console.log(`\n[${theme}]`);
|
||||
}
|
||||
|
||||
for (const story of stories) {
|
||||
// A context per story: reusing one page loses the msw worker
|
||||
// re-registration race after a few navigations and the story then dies on
|
||||
// a missing worker.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: Number(opts.width), height: Number(opts.height) },
|
||||
reducedMotion: opts.motion ? 'no-preference' : 'reduce',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// react-query retries and msw both keep requests going long after load, so
|
||||
// the settle waits on the page being quiet rather than on a fixed delay.
|
||||
let inFlight = 0;
|
||||
let lastActivity = Date.now();
|
||||
page.on('request', () => {
|
||||
inFlight += 1;
|
||||
lastActivity = Date.now();
|
||||
});
|
||||
const done = () => {
|
||||
inFlight = Math.max(inFlight - 1, 0);
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
page.on('requestfinished', done);
|
||||
page.on('requestfailed', done);
|
||||
|
||||
// The height the rounds had reached when the page turned out to grow with
|
||||
// the viewport, kept only to flag the story in the log.
|
||||
let chasing = 0;
|
||||
|
||||
const url = new URL(`${base}/iframe.html`);
|
||||
url.searchParams.set('viewMode', 'story');
|
||||
url.searchParams.set('id', story.id);
|
||||
// The preview owns the clock and the motion state, so both are asked for in
|
||||
// the URL rather than injected here: a Chromatic build gets the defaults.
|
||||
url.searchParams.set('storyClock', opts.clock);
|
||||
const globals = [theme && `theme:${theme}`, opts.motion && 'motion:live']
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
if (globals) {
|
||||
url.searchParams.set('globals', globals);
|
||||
}
|
||||
if (storyArgs) {
|
||||
url.searchParams.set('args', storyArgs);
|
||||
}
|
||||
|
||||
try {
|
||||
await page.goto(url.href, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Storybook's own render phase is the readiness signal: it reaches
|
||||
// `finished` only once the loaders, the decorators and the story's `play`
|
||||
// are all done, which a DOM check cannot see. The dev server transforms
|
||||
// each page module on first visit, so this is the slow wait.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window.__STORYBOOK_PREVIEW__?.storyRenders ?? []).some((render) =>
|
||||
['finished', 'errored', 'aborted'].includes(render.phase),
|
||||
) || document.body.classList.contains('sb-show-errordisplay'),
|
||||
undefined,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
|
||||
await page.addStyleTag({ content: ignoreCss(ignoreSelectors) });
|
||||
if (!opts.motion) {
|
||||
// Videos and GIFs are parked on their first frame, as Chromatic does.
|
||||
await page.evaluate(() =>
|
||||
document.querySelectorAll('video').forEach((video) => video.pause?.()),
|
||||
);
|
||||
}
|
||||
|
||||
// Text reflows when a webfont lands, so the shot waits for the faces the
|
||||
// page asked for. Some stories keep a request open by design, hence the
|
||||
// cap on the quiet wait rather than a plain networkidle.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const quietUntil = Date.now() + 15_000;
|
||||
while (
|
||||
Date.now() < quietUntil &&
|
||||
(inFlight > 0 || Date.now() - lastActivity < 600)
|
||||
) {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
|
||||
// The width is the fixed dimension and the height follows the page, the
|
||||
// way a Chromatic viewport does. `src/styles.scss` pins
|
||||
// `html, body, #root` to `height: 100%; overflow: hidden`, so the
|
||||
// document can never outgrow the viewport and its height says nothing
|
||||
// about what is on the page: what overflows are the shell's inner
|
||||
// scrollers. `scrollers` grows the viewport until the tallest of those
|
||||
// fits, so nothing is cut off and no scrollbar is left in the shot.
|
||||
// Growing changes the layout, hence the rounds. A page that sizes a panel
|
||||
// in `vh` grows its own content as the viewport grows, so no height ever
|
||||
// fits it and the rounds only chase: `.alert-chart-container` is `57vh`,
|
||||
// which puts Create Alert's fixed point at 4344px with an empty band on
|
||||
// top. Such a page is shot at `--height` with its own scrollbar instead,
|
||||
// which is what it looks like in a browser.
|
||||
if (opts.grow !== 'none') {
|
||||
const maximum = Number(opts['max-height']);
|
||||
const requested = Number(opts.height);
|
||||
let height = requested;
|
||||
let fits = false;
|
||||
for (let round = 0; round < 3 && !fits; round += 1) {
|
||||
const needed = Math.min(
|
||||
maximum,
|
||||
await page.evaluate((withScrollers) => {
|
||||
const document_ = Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
);
|
||||
if (!withScrollers) {
|
||||
return document_;
|
||||
}
|
||||
|
||||
// Popups are skipped: they are out of the flow, and a tall
|
||||
// dropdown or tooltip would otherwise drag the shot to a
|
||||
// height nothing on the page itself needs.
|
||||
const inFlow = (element) => {
|
||||
for (
|
||||
let node = element;
|
||||
node && node !== document.documentElement;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const { position } = getComputedStyle(node);
|
||||
if (position === 'fixed' || position === 'absolute') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return [...document.querySelectorAll('*')].reduce((tallest, element) => {
|
||||
const { overflowY } = getComputedStyle(element);
|
||||
if (
|
||||
!['auto', 'scroll', 'overlay'].includes(overflowY) ||
|
||||
element.scrollHeight - element.clientHeight <= 1 ||
|
||||
!inFlow(element)
|
||||
) {
|
||||
return tallest;
|
||||
}
|
||||
|
||||
const box = element.getBoundingClientRect();
|
||||
const above = box.top + window.scrollY;
|
||||
const below = Math.max(0, document_ - (box.bottom + window.scrollY));
|
||||
return Math.max(tallest, above + element.scrollHeight + below);
|
||||
}, document_);
|
||||
}, opts.grow === 'scrollers'),
|
||||
);
|
||||
fits = needed <= height;
|
||||
if (fits) {
|
||||
break;
|
||||
}
|
||||
|
||||
height = Math.ceil(needed);
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
|
||||
if (!fits && height !== requested) {
|
||||
chasing = height;
|
||||
height = requested;
|
||||
await page.setViewportSize({ width: Number(opts.width), height });
|
||||
await page.waitForTimeout(Number(opts.settle));
|
||||
}
|
||||
}
|
||||
|
||||
// The preview snapped its bottom-pinned lists at `afterEach`, before the
|
||||
// page went quiet; a virtuoso list is usually still measuring then.
|
||||
await page.evaluate(() => window.__signozSnapPinnedScrollers?.());
|
||||
|
||||
// A page that is still moving — a list scrolling itself to the bottom, a
|
||||
// monaco editor re-measuring, a tooltip being repositioned — is shot
|
||||
// twice in a row until two frames come back identical, since what the
|
||||
// page is waiting on is not observable from here.
|
||||
let shot = await page.screenshot();
|
||||
let stable = false;
|
||||
for (let attempt = 0; attempt < 8 && !stable; attempt += 1) {
|
||||
await page.waitForTimeout(400);
|
||||
const next = await page.screenshot();
|
||||
stable = next.equals(shot);
|
||||
shot = next;
|
||||
}
|
||||
|
||||
const file = path.join(dir, `${story.id}.png`);
|
||||
await writeFile(file, shot);
|
||||
|
||||
// The band goes on the shot itself so a single screenshot says what it
|
||||
// is, and its height is recorded so a diff can take it back off.
|
||||
let caption = 0;
|
||||
if (captioning) {
|
||||
const temporary = path.join(
|
||||
os.tmpdir(),
|
||||
`story-shots-caption-${process.pid}.png`,
|
||||
);
|
||||
caption = stamp({
|
||||
lines: [
|
||||
`${story.title}/${story.name}`,
|
||||
[story.id, theme ?? 'default', stable ? '' : '(busy)']
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
configLine,
|
||||
].filter(Boolean),
|
||||
from: file,
|
||||
to: temporary,
|
||||
theme: theme ?? 'dark',
|
||||
});
|
||||
await rename(temporary, file);
|
||||
}
|
||||
|
||||
shots.push({
|
||||
file: path.posix.join(
|
||||
opts.flat ? '' : (theme ?? 'default'),
|
||||
`${story.id}.png`,
|
||||
),
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
name: story.name,
|
||||
theme: theme ?? 'default',
|
||||
status: stable ? 'ok' : 'busy',
|
||||
caption,
|
||||
});
|
||||
console.log(
|
||||
` ${stable ? 'ok ' : 'busy'} ${story.id}${
|
||||
chasing ? ` (viewport-sized content, stopped chasing ${chasing}px)` : ''
|
||||
}`,
|
||||
);
|
||||
} catch (error) {
|
||||
failures.push(`${theme ?? 'default'}/${story.id}`);
|
||||
console.log(` FAIL ${story.id}: ${error.message.split('\n')[0]}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// The diff script captions its output from this, so the run's own settings sit
|
||||
// next to the shots they produced rather than only in the shell history.
|
||||
await writeFile(
|
||||
path.join(outDir, 'shots.json'),
|
||||
`${JSON.stringify({ config: runConfig, shots }, null, '\t')}\n`,
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`\n${failures.length} failed: ${failures.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -4,23 +4,15 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
HandleExportRawDataPOSTParams,
|
||||
ListPromotedAndIndexedPaths200,
|
||||
PromotetypesPromotePathDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -28,26 +20,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints allows complex query exporting raw data for traces and logs
|
||||
* @summary Export raw data
|
||||
@@ -149,175 +121,3 @@ export const useHandleExportRawDataPOST = <
|
||||
> => {
|
||||
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoints promotes and indexes paths
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
|
||||
url: `/api/v1/logs/promote_paths`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPromotedAndIndexedPathsQueryKey = () => {
|
||||
return [`/api/v1/logs/promote_paths`] as const;
|
||||
};
|
||||
|
||||
export const getListPromotedAndIndexedPathsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
|
||||
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
|
||||
>;
|
||||
export type ListPromotedAndIndexedPathsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
|
||||
export function useListPromotedAndIndexedPaths<
|
||||
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const invalidateListPromotedAndIndexedPaths = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoints promotes and indexes paths
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const handlePromoteAndIndexPaths = (
|
||||
promotetypesPromotePathDTONull?: BodyType<
|
||||
PromotetypesPromotePathDTO[] | null
|
||||
> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/logs/promote_paths`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPromotePathDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getHandlePromoteAndIndexPathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['handlePromoteAndIndexPaths'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return handlePromoteAndIndexPaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
|
||||
>;
|
||||
export type HandlePromoteAndIndexPathsMutationBody =
|
||||
| BodyType<PromotetypesPromotePathDTO[] | null>
|
||||
| undefined;
|
||||
export type HandlePromoteAndIndexPathsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote and index paths
|
||||
*/
|
||||
export const useHandlePromoteAndIndexPaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
|
||||
};
|
||||
|
||||
232
frontend/src/api/generated/services/promote/index.ts
Normal file
232
frontend/src/api/generated/services/promote/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
ListPromotedPaths200,
|
||||
ListPromotedPathsParams,
|
||||
PromotetypesPromotePathDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
export const listPromotedPaths = (
|
||||
params?: ListPromotedPathsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListPromotedPaths200>({
|
||||
url: `/api/v1/promoted_paths`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListPromotedPathsQueryKey = (
|
||||
params?: ListPromotedPathsParams,
|
||||
) => {
|
||||
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListPromotedPathsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>
|
||||
> = ({ signal }) => listPromotedPaths(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListPromotedPathsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>
|
||||
>;
|
||||
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
|
||||
export function useListPromotedPaths<
|
||||
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listPromotedPaths>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListPromotedPathsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List promoted paths
|
||||
*/
|
||||
export const invalidateListPromotedPaths = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListPromotedPathsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListPromotedPathsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
|
||||
* @summary Promote paths
|
||||
*/
|
||||
export const promotePaths = (
|
||||
promotetypesPromotePathDTONull?: BodyType<
|
||||
PromotetypesPromotePathDTO[] | null
|
||||
> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/promoted_paths`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: promotetypesPromotePathDTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPromotePathsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['promotePaths'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return promotePaths(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PromotePathsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof promotePaths>>
|
||||
>;
|
||||
export type PromotePathsMutationBody =
|
||||
| BodyType<PromotetypesPromotePathDTO[] | null>
|
||||
| undefined;
|
||||
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Promote paths
|
||||
*/
|
||||
export const usePromotePaths = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof promotePaths>>,
|
||||
TError,
|
||||
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPromotePathsMutationOptions(options));
|
||||
};
|
||||
@@ -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,101 +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 type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5483,8 +5382,7 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6411,7 +6309,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -9622,6 +9519,10 @@ export interface PromotetypesWrappedIndexDTO {
|
||||
}
|
||||
|
||||
export interface PromotetypesPromotePathDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
context: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9629,11 +9530,15 @@ export interface PromotetypesPromotePathDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
path?: string;
|
||||
path: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
promote?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
@@ -12937,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
|
||||
@@ -12973,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -51,7 +51,6 @@ 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:
|
||||
return 'scalar';
|
||||
@@ -279,7 +278,6 @@ export function createAggregation(
|
||||
queryData.dataSource === DataSource.METRICS &&
|
||||
panelType &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import LabelColumn from '../LabelColumn';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Label Column',
|
||||
component: LabelColumn,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 180 })],
|
||||
args: {
|
||||
labels: ['severity', 'team', 'service', 'environment'],
|
||||
value: {
|
||||
severity: 'critical',
|
||||
team: 'payments',
|
||||
service: 'checkout',
|
||||
environment: 'production',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof LabelColumn>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Labels that fit the column; the rest collapse into a `+N` chip. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Copying one label from its tooltip. */
|
||||
export const LabelCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.hover(
|
||||
await within(canvasElement).findByTestId('label-tag-severity'),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy to clipboard' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Copying the labels that overflow the column from the `+N` chip's tooltip. */
|
||||
export const OverflowLabelsCopiedToast: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.hover(
|
||||
await within(canvasElement).findByTestId('label-overflow-badge'),
|
||||
);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Copy to clipboard' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
@@ -56,6 +56,21 @@ export const UnsavedChanges: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Saving: the toast raised once the edited columns are applied. */
|
||||
export const SavedToast: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
|
||||
|
||||
await userEvent.click(addField);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Save changes' }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Saved successfully')).toBeVisible(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Empty: the suggestion request succeeds with no columns to add. */
|
||||
export const NoResults: Story = {
|
||||
parameters: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Header Right Section',
|
||||
component: HeaderRightSection,
|
||||
tags: ['play'],
|
||||
args: {
|
||||
enableFeedback: true,
|
||||
enableShare: true,
|
||||
enableAnnouncements: false,
|
||||
},
|
||||
} satisfies Meta<typeof HeaderRightSection>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** The top bar's actions: feedback and share. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A feedback note submitted from the popover, which toasts once it is sent. */
|
||||
export const FeedbackSubmittedToast: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('button', { name: 'Feedback' }),
|
||||
);
|
||||
await userEvent.type(
|
||||
await screen.findByPlaceholderText(/write your feedback here/i),
|
||||
'The new explorer is great',
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /submit/i }));
|
||||
await screen.findByText(/feedback submitted successfully/i);
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export interface CustomMultiSelectProps extends Omit<
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showLabels?: boolean;
|
||||
preserveOptionOrder?: boolean;
|
||||
enableRegexOption?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
|
||||
@@ -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[])
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.overflowValues {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -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`. */
|
||||
@@ -518,10 +518,7 @@ function QueryAddOns({
|
||||
type="number"
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder={
|
||||
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
|
||||
'Enter limit'
|
||||
}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
|
||||
@@ -43,9 +43,7 @@ function QueryAggregationOptions({
|
||||
|
||||
if (
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE)
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/** Throws until `found` holds something, which is what `waitFor` retries on. */
|
||||
const present = <TValue>(
|
||||
found: TValue | null | undefined,
|
||||
what: string,
|
||||
): TValue => {
|
||||
if (found === null || found === undefined) {
|
||||
throw new Error(`${what} not found`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
|
||||
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
|
||||
|
||||
const suggestionRow = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): HTMLElement | undefined => {
|
||||
const list = suggestionList(canvasElement);
|
||||
|
||||
return list
|
||||
? within(list)
|
||||
.queryAllByRole('option')
|
||||
.find((option) => option.textContent?.includes(text))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
|
||||
const requestSuggestions = (editor: HTMLElement): void => {
|
||||
editor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const currentView = (
|
||||
canvasElement: HTMLElement,
|
||||
): { editor: HTMLElement; view: EditorView } => {
|
||||
// An explorer renders one editor per query; the first is the one on screen.
|
||||
const editor = present(
|
||||
canvasElement.querySelector<HTMLElement>(
|
||||
'.code-mirror-where-clause .cm-content',
|
||||
),
|
||||
'filter editor',
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
view: present(EditorView.findFromDOM(editor), 'editor view'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
|
||||
* the browser is idle. Initialising moves the content, the editor with it, and
|
||||
* focuses the editor again through the DOM, which puts the caret back at the
|
||||
* start and swaps the suggestions for the key list. Throws until every wrapper
|
||||
* around the filter has initialised.
|
||||
*/
|
||||
const assertScrollbarsReady = (editor: HTMLElement): void => {
|
||||
for (
|
||||
let wrapper = editor.closest('.overlay-scrollbar');
|
||||
wrapper;
|
||||
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
|
||||
) {
|
||||
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
|
||||
throw new Error('scrollbars around the filter still initialising');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until `text` shows in the suggestion list, asking for suggestions
|
||||
* whenever the list is shut. Focus and typing only open it once the keys have
|
||||
* loaded, and moving the caret never does.
|
||||
*/
|
||||
const waitForSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> =>
|
||||
waitFor(
|
||||
() => {
|
||||
const { editor } = currentView(canvasElement);
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
/**
|
||||
* Focuses the filter once the scrollbars around it have initialised, and waits
|
||||
* for its suggestion list.
|
||||
*/
|
||||
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { editor, view } = currentView(canvasElement);
|
||||
|
||||
assertScrollbarsReady(editor);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
view.focus();
|
||||
}
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionList(canvasElement), 'suggestion list');
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
return currentView(canvasElement).view;
|
||||
};
|
||||
|
||||
/** Waits for a row of the suggestion list. */
|
||||
export const findSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
|
||||
|
||||
/** Focuses the empty filter: every key, with any recent filters above them. */
|
||||
export const openKeySuggestions = async (
|
||||
canvasElement: HTMLElement,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await focusFilter(canvasElement);
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses the filter and types onto the end of it one character at a time,
|
||||
* each as the transaction a keystroke makes, leaving the caret at the end so
|
||||
* the suggestion list follows what was typed. Quotes and brackets are not
|
||||
* closed for it: type both.
|
||||
*
|
||||
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
|
||||
* highlighted, which strands the caret `userEvent` tracks.
|
||||
*/
|
||||
export const typeFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const view = await focusFilter(canvasElement);
|
||||
|
||||
for (const character of text) {
|
||||
const at = view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: at, insert: character },
|
||||
selection: { anchor: at + character.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Types an expression, then steps the caret back inside it, before a closing
|
||||
* bracket or parenthesis, where the suggestions are about what goes in there.
|
||||
*/
|
||||
export const typeFilterWithCaretBack = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
stepsBack: number,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
|
||||
const { view } = currentView(canvasElement);
|
||||
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.doc.length - stepsBack },
|
||||
userEvent: 'select',
|
||||
});
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves focus off the filter, which is when the expression is validated and
|
||||
* the error marker can show.
|
||||
*/
|
||||
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
};
|
||||
|
||||
/** Types an expression, leaves the filter and opens its validation errors. */
|
||||
export const showFilterErrors = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
await blurFilter(canvasElement);
|
||||
|
||||
const marker = await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
|
||||
'error marker',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.hover(marker);
|
||||
await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.ownerDocument.querySelector('.query-validation-error'),
|
||||
'validation error',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -95,7 +95,6 @@ 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),
|
||||
[dataSource, panelType],
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -170,33 +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',
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-opens Order By and Limit when present in query', () => {
|
||||
const query = baseQuery({
|
||||
orderBy: [{ columnName: 'duration', order: 'desc' }],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -57,19 +57,6 @@ describe('resolveQueryBuilderField', () => {
|
||||
expect(resolved.hidden).toBe(false);
|
||||
});
|
||||
|
||||
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' },
|
||||
|
||||
@@ -22,12 +22,8 @@ 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.
|
||||
*/
|
||||
/** `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' };
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) 10.5px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.left-actions {
|
||||
@@ -46,6 +46,9 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
.text {
|
||||
padding-left: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -78,6 +81,8 @@
|
||||
}
|
||||
|
||||
.select-box {
|
||||
--combobox-trigger-width: auto;
|
||||
|
||||
display: flex;
|
||||
padding: 2px 5px;
|
||||
flex-direction: row;
|
||||
@@ -95,10 +100,9 @@
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
--button-variant-link-color: var(--l1-foreground);
|
||||
|
||||
.divider-filter {
|
||||
width: 1px;
|
||||
@@ -106,26 +110,8 @@
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-action-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.settings-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
.active {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Filter,
|
||||
Frown,
|
||||
RefreshCw,
|
||||
Settings2 as SettingsIcon,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -180,7 +180,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
// Helpers to reduce cognitive complexity in main render
|
||||
const renderLeftActions = (): JSX.Element => (
|
||||
<section className="left-actions">
|
||||
<Filter size="md" />
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
@@ -217,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
</Combobox>
|
||||
) : (
|
||||
displayedQueryName && (
|
||||
<Tooltip
|
||||
<TooltipSimple
|
||||
title={`Filter currently in sync with query ${displayedQueryName}`}
|
||||
>
|
||||
<Typography.Text className="sync-tag">
|
||||
{displayedQueryName}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
@@ -231,51 +230,54 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderRightActions = (): JSX.Element => (
|
||||
<section className="right-actions">
|
||||
<Tooltip title="Reset All">
|
||||
<TooltipSimple title="Reset All">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Reset All"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleReset}
|
||||
prefix={<RefreshCw className="sync-icon" size="md" />}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && (
|
||||
<Tooltip title="Collapse Filters">
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Collapse Filters"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDynamicFilters && (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
aria-label="Settings"
|
||||
className={classNames('right-action-icon-container', {
|
||||
active: isSettingsOpen,
|
||||
})}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={
|
||||
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
|
||||
<SettingsIcon
|
||||
className="settings-icon"
|
||||
data-testid="settings-icon"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isDynamicFilters &&
|
||||
(isSettingsDisabled ? (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title="Settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
className={classNames({ active: isSettingsOpen })}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
data-testid="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
@@ -40,7 +41,7 @@ function QuickFiltersLayout({
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsOpen, setIsViewsOpen] = useState(false);
|
||||
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
@@ -48,19 +49,23 @@ function QuickFiltersLayout({
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isPanelPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsOpen;
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{hasSavedViews && <SavedViewsRestore source={savedViewProps.source} />}
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
@@ -80,10 +85,12 @@ function QuickFiltersLayout({
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="saved-views-restore" data-source={source} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
@@ -87,6 +94,10 @@ describe('QuickFiltersLayout', () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-restore')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
@@ -149,6 +160,7 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
@@ -176,12 +188,14 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
|
||||
@@ -117,3 +117,38 @@ export const Tooltips: TooltipsStory = {
|
||||
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
|
||||
},
|
||||
};
|
||||
|
||||
const [firstQuery] = queryBuilder.currentQuery.builder.queryData;
|
||||
|
||||
/** Interaction: the query picker lists every query the filters can follow. */
|
||||
export const QueryPickerOpen: Story = {
|
||||
parameters: {
|
||||
signoz: {
|
||||
queryBuilder: {
|
||||
...queryBuilder,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{ ...firstQuery, queryName: 'A' },
|
||||
{ ...firstQuery, queryName: 'B' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await waitFor(() => {
|
||||
const element = canvasElement.querySelector<HTMLElement>(
|
||||
'[data-slot="combobox-trigger"]',
|
||||
);
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
|
||||
return element as HTMLElement;
|
||||
});
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -233,12 +233,23 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
// A rebuilt column def is a new cell component to React, which remounts every
|
||||
// cell it renders. Read the row callbacks through refs so inline ones do not.
|
||||
const isRowActiveRef = useRef(isRowActive);
|
||||
isRowActiveRef.current = isRowActive;
|
||||
const getRowKeyDataRef = useRef(getRowKeyData);
|
||||
getRowKeyDataRef.current = getRowKeyData;
|
||||
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
|
||||
() =>
|
||||
effectiveColumns.map((colDef) =>
|
||||
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
|
||||
buildTanstackColumnDef<TData, TItemKey>(
|
||||
colDef,
|
||||
(row) => isRowActiveRef.current?.(row) ?? false,
|
||||
(index) => getRowKeyDataRef.current(index),
|
||||
),
|
||||
),
|
||||
[effectiveColumns, isRowActive, getRowKeyData],
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
const getRowId = useCallback(
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
|
||||
import { renderTanStackTable } from './testUtils';
|
||||
import TanStackTable from '../index';
|
||||
import type { TableColumnDef } from '../types';
|
||||
import {
|
||||
defaultData,
|
||||
renderTanStackTable,
|
||||
renderWithProviders,
|
||||
type TestRow,
|
||||
} from './testUtils';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
@@ -810,6 +818,58 @@ describe('TanStackTableView Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cell identity', () => {
|
||||
it('keeps cells mounted across re-renders that pass new inline row callbacks', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMount = jest.fn();
|
||||
|
||||
function MountTracker({ id }: { id: string }): JSX.Element {
|
||||
useEffect(() => onMount(id), [id]);
|
||||
return <span data-testid={`tracked-${id}`}>Row {id}</span>;
|
||||
}
|
||||
|
||||
const columns: TableColumnDef<TestRow>[] = [
|
||||
{
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }): JSX.Element => <MountTracker id={row.id} />,
|
||||
},
|
||||
];
|
||||
|
||||
function Harness(): JSX.Element {
|
||||
const [, setRenders] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rerender"
|
||||
onClick={(): void => setRenders((count) => count + 1)}
|
||||
>
|
||||
Re-render
|
||||
</button>
|
||||
<TanStackTable<TestRow>
|
||||
data={defaultData}
|
||||
columns={columns}
|
||||
getRowKey={(row): string => row.id}
|
||||
isRowActive={(row): boolean => row.id === '1'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<Harness />);
|
||||
await screen.findByTestId('tracked-2');
|
||||
onMount.mockClear();
|
||||
|
||||
await user.click(screen.getByTestId('rerender'));
|
||||
await user.hover(screen.getByTestId('tracked-2'));
|
||||
|
||||
expect(onMount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableVirtualScroll', () => {
|
||||
it('throws error when used with onEndReached', () => {
|
||||
expect(() => {
|
||||
|
||||
@@ -143,6 +143,19 @@ export const Pagination: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: the page size menu lists the row counts a page can show. */
|
||||
export const PageSizeOpen: Story = {
|
||||
args: {
|
||||
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
|
||||
},
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('pagination-page-size'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
|
||||
export const Empty: Story = {
|
||||
args: { data: [], testId: 'tanstack-empty-table' },
|
||||
|
||||
@@ -36,7 +36,6 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HEATMAP]: 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -337,7 +337,6 @@ export enum PANEL_TYPES {
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
SCATTER = 'scatter',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { SavedViewSourcePage } from 'container/SavedViews/types';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -32,8 +32,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import history from 'lib/history';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import * as motion from 'motion/react-client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
20
frontend/src/container/InfraMonitoringK8sV2/Nodes/utils.ts
Normal file
20
frontend/src/container/InfraMonitoringK8sV2/Nodes/utils.ts
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]>();
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator: string;
|
||||
aggregateOperator?: string;
|
||||
initialOptions?: InitialOptions;
|
||||
}
|
||||
|
||||
|
||||
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
|
||||
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
icon={<Check size={14} />}
|
||||
color="warning"
|
||||
disabled={disabled}
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveChangesMenu;
|
||||
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
94
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
94
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { SAVE_VIEW_MODAL_TITLE } from './constants';
|
||||
import { SaveViewModalProps } from './types';
|
||||
|
||||
import styles from './SaveViewModal.module.scss';
|
||||
|
||||
const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!displayName || isSaving) {
|
||||
return;
|
||||
}
|
||||
if (await onSave(displayName)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={SAVE_VIEW_MODAL_TITLE[mode]}
|
||||
testId="save-view-modal"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
testId="save-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isSaving}
|
||||
disabled={!displayName}
|
||||
onClick={(): void => {
|
||||
void handleSave();
|
||||
}}
|
||||
testId="save-view-submit"
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form className={styles.field} onSubmit={handleSubmit}>
|
||||
<label htmlFor={NAME_INPUT_ID}>
|
||||
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
|
||||
</label>
|
||||
<Input
|
||||
id={NAME_INPUT_ID}
|
||||
placeholder="Eg. payments-view"
|
||||
value={name}
|
||||
onChange={(event): void => setName(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
testId="save-view-name"
|
||||
/>
|
||||
</form>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user