Compare commits
15 Commits
main
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93112e5530 | ||
|
|
cd00004cd6 | ||
|
|
1d887c6e45 | ||
|
|
79f6b5882d | ||
|
|
70daa1e513 | ||
|
|
7ea95e98c2 | ||
|
|
71cfa308b7 | ||
|
|
06a0c4f959 | ||
|
|
2516d1a72c | ||
|
|
4d89ddf3c0 | ||
|
|
55ad8d2d2a | ||
|
|
307aceccf2 | ||
|
|
770eb2f549 | ||
|
|
5cfe19a9bc | ||
|
|
2c91c43841 |
@@ -1,15 +0,0 @@
|
||||
---
|
||||
paths:
|
||||
- "frontend/src/**/*.tsx"
|
||||
---
|
||||
|
||||
# Storybook
|
||||
|
||||
Page stories are written with the `signoz-page-story` skill ([`.claude/skills/signoz-page-story/SKILL.md`](../skills/signoz-page-story/SKILL.md)); the API surface is [`frontend/src/storybook/README.md`](../../frontend/src/storybook/README.md).
|
||||
|
||||
- **A UI change lands with its story.** Any change that alters what a page renders — a new page, a new state, a new branch, a changed layout — updates the story of every page that renders it, or creates one where the page has none. Go through the `signoz-page-story` skill; never hand-write a page story outside it.
|
||||
- **New page, new story. New state, new control or variant.** The skill decides which: a control on a response, a variant worth linking to, or a `play` story for a state only a click reaches.
|
||||
- **A shared component is covered through its pages.** A change under `components/` or `container/` with no story of its own updates the stories of the pages that render it.
|
||||
- **Already covered, say so.** When the existing story and its controls already reach the changed state, no story edit is needed; state that in the PR description.
|
||||
- **Opt out only when there is no visual diff.** Skip stories only when the change renders nothing differently: types, api clients, hooks with no UI branch, tests. Deleting a page deletes its stories.
|
||||
- **Prove it with a visual diff when asked.** Before/after screenshots and "did this touch other screens" go through `sbshot-visual-diff`, which can only show what a story renders.
|
||||
@@ -5,9 +5,15 @@ description: Shoot Storybook stories with sbshot at two git refs and produce a d
|
||||
|
||||
# sbshot visual diffs
|
||||
|
||||
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs. Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare: sbshot already handles story selection, themes, settling, retries, diffing, tagging, a viewer and a static export, and its runs are the format the rest of the team can open.
|
||||
`npx -y github:SigNoz/sbshot` shoots Storybook stories and pixel-diffs two runs.
|
||||
Use it. Do not hand-roll a Playwright screenshot loop or a canvas pixel-compare:
|
||||
sbshot already handles story selection, themes, settling, retries, diffing,
|
||||
tagging, a viewer and a static export, and its runs are the format the rest of
|
||||
the team can open.
|
||||
|
||||
sbshot is not installed by any package. It is run through `npx`, so nothing it knows travels into this repo on its own. This file is where that knowledge lives.
|
||||
sbshot is not installed by any package. It is run through `npx`, so nothing it
|
||||
knows travels into this repo on its own. This file is where that knowledge
|
||||
lives.
|
||||
|
||||
## The shape of a run
|
||||
|
||||
@@ -18,31 +24,58 @@ tag <diff> <tag> <story-id>... # what the reviewer should look at
|
||||
export <diff> --out <dir> # a static site, hostable
|
||||
```
|
||||
|
||||
`<storybook>` is a running dev server (`http://localhost:6006`), a build directory holding `index.json`, or a project directory with `.storybook/`. The frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so point `capture` at a dev server.
|
||||
`<storybook>` is a running dev server (`http://localhost:6006`), a build
|
||||
directory holding `index.json`, or a project directory with `.storybook/`. The
|
||||
frontend has **no `build-storybook` script**, only `storybook dev -p 6006`, so
|
||||
point `capture` at a dev server.
|
||||
|
||||
## Cover the change with stories first
|
||||
|
||||
**A diff can only show what a story renders.** A screen, state or variant with no story is invisible to sbshot: it produces no pair, so it reads as "nothing changed" rather than "never looked at". This is the failure mode that makes a clean-looking diff worthless.
|
||||
**A diff can only show what a story renders.** A screen, state or variant with no
|
||||
story is invisible to sbshot: it produces no pair, so it reads as "nothing
|
||||
changed" rather than "never looked at". This is the failure mode that makes a
|
||||
clean-looking diff worthless.
|
||||
|
||||
Before capturing, list the screens and states the change touches and check each has a story. For anything missing, write it first — the `signoz-page-story` skill covers how. New states reached only by an interaction need a `play` function, or the capture shoots the page at rest and the state never appears.
|
||||
Before capturing, list the screens and states the change touches and check each
|
||||
has a story. For anything missing, write it first — the `signoz-page-story`
|
||||
skill covers how. New states reached only by an interaction need a `play`
|
||||
function, or the capture shoots the page at rest and the state never appears.
|
||||
|
||||
Then confirm it worked: the two runs' story counts should differ by exactly the stories the change adds. A new screen that produced no new story id is a screen nobody is reviewing. Say the count in the summary, so a reader can tell coverage from silence.
|
||||
Then confirm it worked: the two runs' story counts should differ by exactly the
|
||||
stories the change adds. A new screen that produced no new story id is a screen
|
||||
nobody is reviewing. Say the count in the summary, so a reader can tell coverage
|
||||
from silence.
|
||||
|
||||
## Rules that are not optional
|
||||
|
||||
**One freshly started Storybook per ref.** Never start one server and switch git branches under it. Vite's HMR reloads the page, but some modules do not re-initialise, so the shot differs silently from a clean boot and the difference looks like a real change. Settle time does not fix this and is not the variable. Use one worktree and one port per ref, and stop each server before the next.
|
||||
**One freshly started Storybook per ref.** Never start one server and switch git
|
||||
branches under it. Vite's HMR reloads the page, but some modules do not
|
||||
re-initialise, so the shot differs silently from a clean boot and the difference
|
||||
looks like a real change. Settle time does not fix this and is not the variable.
|
||||
Use one worktree and one port per ref, and stop each server before the next.
|
||||
|
||||
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the story's own default and everything lands in a single `default/` folder, so every regression in the other theme is invisible. Themes double the shot count, so scope with `--stories` or `--title`, never by dropping a theme.
|
||||
**Capture both themes: `--theme dark,light`.** Without it sbshot shoots the
|
||||
story's own default and everything lands in a single `default/` folder, so every
|
||||
regression in the other theme is invisible. Themes double the shot count, so
|
||||
scope with `--stories` or `--title`, never by dropping a theme.
|
||||
|
||||
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main <branch>`. Against a moved main, every commit main gained since the branch's base shows up reversed, as though the branch reverted it.
|
||||
**Baseline is the merge-base, not `origin/main`.** `git merge-base origin/main
|
||||
<branch>`. Against a moved main, every commit main gained since the branch's base
|
||||
shows up reversed, as though the branch reverted it.
|
||||
|
||||
**The percentage sorts the list, it does not tell you what matters.** A dialog that dims the page scores high for a one-line change, and a whole new form section can score low. Open the shots and say what changed on each screen. The biggest number is usually the least interesting one.
|
||||
**The percentage sorts the list, it does not tell you what matters.** A dialog
|
||||
that dims the page scores high for a one-line change, and a whole new form
|
||||
section can score low. Open the shots and say what changed on each screen. The
|
||||
biggest number is usually the least interesting one.
|
||||
|
||||
**If you cannot explain a pair, assume the capture is at fault, not the code.** Open it, crop both sides and compare them before calling it noise. A pixel count and a good guess are not evidence.
|
||||
**If you cannot explain a pair, assume the capture is at fault, not the code.**
|
||||
Open it, crop both sides and compare them before calling it noise. A pixel count
|
||||
and a good guess are not evidence.
|
||||
|
||||
## Noise, and the floor that proves it
|
||||
|
||||
The same story does not always render identically twice. Pairs often move for reasons that have nothing to do with the change:
|
||||
The same story does not always render identically twice. Pairs often move for
|
||||
reasons that have nothing to do with the change:
|
||||
|
||||
- a focus ring left on whatever a `play` function clicked last
|
||||
- a scrollbar appearing, or its thumb resting a few pixels off
|
||||
@@ -53,7 +86,9 @@ The same story does not always render identically twice. Pairs often move for re
|
||||
- hover or active styling from where the pointer happened to rest
|
||||
- a late-loading font or image shifting a line of text
|
||||
|
||||
Do not argue about these one at a time. **Measure them**: shoot the same commit twice and diff those two runs against each other. Anything that moves there moved for no reason, so it is noise.
|
||||
Do not argue about these one at a time. **Measure them**: shoot the same commit
|
||||
twice and diff those two runs against each other. Anything that moves there moved
|
||||
for no reason, so it is noise.
|
||||
|
||||
```
|
||||
capture <storybook> --name-run noise-a
|
||||
@@ -62,35 +97,76 @@ diff noise-a noise-b --name-diff noise
|
||||
diff <baseline> <after> --noise noise # marks the movers flaky
|
||||
```
|
||||
|
||||
**The noise run has to cover the same stories as the real diff.** Do not scope it down to the few pairs that moved, however tempting that is. `--noise` marks a pair flaky when the noise diff does not show it as unchanged, so every story the noise run leaves out is marked flaky too, including the new screens the reviewer is there to look at. Shooting two narrow runs and diffing those against each other has the same problem. Either shoot the full set twice, or skip `--noise` and explain the few movers by hand.
|
||||
**The noise run has to cover the same stories as the real diff.** Do not scope it
|
||||
down to the few pairs that moved, however tempting that is. `--noise` marks a
|
||||
pair flaky when the noise diff does not show it as unchanged, so every story the
|
||||
noise run leaves out is marked flaky too, including the new screens the reviewer
|
||||
is there to look at. Shooting two narrow runs and diffing those against each
|
||||
other has the same problem. Either shoot the full set twice, or skip `--noise`
|
||||
and explain the few movers by hand.
|
||||
|
||||
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run hides a real 8% change in the real one. Compare the two numbers before trusting the mark. When most of the flaky marks are hiding real changes, run the diff without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing real is filtered out of the reviewer's view.
|
||||
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the
|
||||
noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run
|
||||
hides a real 8% change in the real one. Compare the two numbers before trusting
|
||||
the mark. When most of the flaky marks are hiding real changes, run the diff
|
||||
without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing
|
||||
real is filtered out of the reviewer's view.
|
||||
|
||||
That is the trade: a second full capture costs about as long as the first, so on a small diff it is often quicker to crop the two or three movers and say what each one is. Use `--noise` when the diff is large, or when wiring `--fail-over` into CI, where something has to decide without a person looking.
|
||||
That is the trade: a second full capture costs about as long as the first, so on
|
||||
a small diff it is often quicker to crop the two or three movers and say what
|
||||
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
|
||||
into CI, where something has to decide without a person looking.
|
||||
|
||||
If a pair is flaky every run and the state it captures matters, fix the story rather than living with it: blur what the `play` clicked, freeze the clock, or assert the state instead of shooting it.
|
||||
If a pair is flaky every run and the state it captures matters, fix the story
|
||||
rather than living with it: blur what the `play` clicked, freeze the clock, or
|
||||
assert the state instead of shooting it.
|
||||
|
||||
## Making it readable for a reviewer
|
||||
|
||||
A reviewer cannot open `localhost`, so `sbshot ui` is for you, not for them.
|
||||
|
||||
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by tag, so the reviewer lands on the real changes instead of scrolling past noise that scored higher.
|
||||
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs that are not identical, converted to WebP, so a large diff stays small.
|
||||
3. Host it. A Claude artifact works: publish the export's files and give the reviewer the link. `index.html` is reserved for the artifact's own page, so publish sbshot's entry as `viewer.html` and link to `viewer.html#/job/diff/<diff>`.
|
||||
1. `tag` the pairs that matter, with `--note` saying why. The viewer filters by
|
||||
tag, so the reviewer lands on the real changes instead of scrolling past
|
||||
noise that scored higher.
|
||||
2. `export <diff> --out <dir>`. It writes a static site carrying only the pairs
|
||||
that are not identical, converted to WebP, so a large diff stays small.
|
||||
3. Host it. A Claude artifact works: publish the export's files and give the
|
||||
reviewer the link. `index.html` is reserved for the artifact's own page, so
|
||||
publish sbshot's entry as `viewer.html` and link to
|
||||
`viewer.html#/job/diff/<diff>`.
|
||||
4. Put the link in the PR body under **Screenshots / Screen Recordings**.
|
||||
|
||||
Say the numbers plainly: how many screens changed, how many are new, how many are pixel-identical, how many are flaky. The count of unchanged screens is often the most useful claim in the diff, because it is the one a reviewer cannot check by eye.
|
||||
Say the numbers plainly: how many screens changed, how many are new, how many are
|
||||
pixel-identical, how many are flaky. The count of unchanged screens is often the
|
||||
most useful claim in the diff, because it is the one a reviewer cannot check by
|
||||
eye.
|
||||
|
||||
## Operational traps
|
||||
|
||||
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or `--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
|
||||
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how many to shoot at once by sampling free memory, and off Linux it reads `os.freemem()`, which counts only truly free pages. macOS parks almost everything in "inactive" instead, which is reclaimable on demand, so the sampler can read ~100 MB on a 25 GB machine and shrink to one forever. `--jobs` does not help: it is only an upper cap. Check `parallelism` in `<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes inactive, that is this. A 124-shot run takes about 90 minutes pinned and about 6 minutes at four at a time, so it is worth spotting before a long capture.
|
||||
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is gitignored and generated. Run `node ./i18-generate-hash.cjs` in `<worktree>/frontend`, and symlink `node_modules` from the main checkout.
|
||||
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if something already holds that port. Free it first.
|
||||
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails with ``unable to read font `' ``. Map Helvetica to a real file in `~/.config/ImageMagick/type.xml`.
|
||||
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it symlinks. Keep the source directory alive until you have exported.
|
||||
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
|
||||
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
|
||||
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how
|
||||
many to shoot at once by sampling free memory, and off Linux it reads
|
||||
`os.freemem()`, which counts only truly free pages. macOS parks almost
|
||||
everything in "inactive" instead, which is reclaimable on demand, so the
|
||||
sampler can read ~100 MB on a 25 GB machine and shrink to one forever.
|
||||
`--jobs` does not help: it is only an upper cap. Check `parallelism` in
|
||||
`<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes
|
||||
inactive, that is this. A 124-shot run takes about 90 minutes pinned and about
|
||||
6 minutes at four at a time, so it is worth spotting before a long capture.
|
||||
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
|
||||
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
|
||||
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.
|
||||
- `pnpm test:storybook` starts its own server on 6006 and hangs silently if
|
||||
something already holds that port. Free it first.
|
||||
- ImageMagick on macOS may have no default font, so `--mode green-parallel` fails
|
||||
with ``unable to read font `' ``. Map Helvetica to a real file in
|
||||
`~/.config/ImageMagick/type.xml`.
|
||||
- `sbshot link <dir> <name>` adds a run from elsewhere into the workspace, but it
|
||||
symlinks. Keep the source directory alive until you have exported.
|
||||
- The export directory is build output. Delete it when done, or gitignore it.
|
||||
- Running the whole Storybook test suite locally fails broadly with `page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
|
||||
- Running the whole Storybook test suite locally fails broadly with
|
||||
`page.goto: Timeout` from resource starvation. Scope it; CI is the real signal.
|
||||
|
||||
## Done means
|
||||
|
||||
|
||||
@@ -5,35 +5,63 @@ description: Explore a SigNoz page, map the endpoints, states and query params i
|
||||
|
||||
# SigNoz page stories
|
||||
|
||||
A page story renders the real page inside the real app shell against msw, and its controls panel can reach every state the page has. The panel is the deliverable, not the story list.
|
||||
A page story renders the real page inside the real app shell against msw, and its
|
||||
controls panel can reach every state the page has. The panel is the deliverable,
|
||||
not the story list.
|
||||
|
||||
`frontend/src/storybook/README.md` is the API surface (providers, parameters, control builders, module mocks, navigation). Read it first; this skill is the process on top of it.
|
||||
`frontend/src/storybook/README.md` is the API surface (providers, parameters,
|
||||
control builders, module mocks, navigation). Read it first; this skill is the
|
||||
process on top of it.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce the inventory (endpoints, states, params, permissions, caps) before writing code. No inventory, no story.
|
||||
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console names the endpoints step 1 missed.
|
||||
1. **Map the page**: [references/discovery.md](references/discovery.md). Produce
|
||||
the inventory (endpoints, states, params, permissions, caps) before writing
|
||||
code. No inventory, no story.
|
||||
2. **Skeleton first**: story + empty `defineStoryMocks`, then run it. The console
|
||||
names the endpoints step 1 missed.
|
||||
3. **Inventory to controls**: [references/controls.md](references/controls.md).
|
||||
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's mocks module.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never report the story as done without it.
|
||||
4. **Mock data and handlers**: builders in `__story_mockdata__`, handlers in the page's
|
||||
mocks module.
|
||||
5. **Verify in the browser**: [references/verify.md](references/verify.md). Never
|
||||
report the story as done without it.
|
||||
|
||||
## Where it lands in the sidebar
|
||||
|
||||
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so a page sits where someone would click it in the product. Four things decide that, and all four are part of writing the story, not a follow-up.
|
||||
The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`), so
|
||||
a page sits where someone would click it in the product. Four things decide that,
|
||||
and all four are part of writing the story, not a follow-up.
|
||||
|
||||
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>` is the label the nav gives it.
|
||||
**Title.** `Pages/<Area>/<Page>`, where `<Area>` is the nav section and `<Page>`
|
||||
is the label the nav gives it.
|
||||
|
||||
- The leaf is the product's label, never the component's name: `MetricsExplorer` is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`, `AIAssistantPage` is `Noz`.
|
||||
- The leaf is the product's label, never the component's name: `MetricsExplorer`
|
||||
is `Metrics/Explorer`, `MeterExplorer` is `Metering/Cost Meter`,
|
||||
`AIAssistantPage` is `Noz`.
|
||||
- Never repeat the area in the leaf: `Alerts/Rules`, not `Alerts/AlertRules`.
|
||||
- A leaf never shares its name with a sibling folder. The folder wins and the page becomes `List`, or `Overview` for a tab strip: `Services/List` beside `Services/Detail`.
|
||||
- A leaf never shares its name with a sibling folder. The folder wins and the
|
||||
page becomes `List`, or `Overview` for a tab strip: `Services/List` beside
|
||||
`Services/Detail`.
|
||||
- Title Case with spaces. No camelCase, no kebab.
|
||||
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep as it goes.
|
||||
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`, `Unauthorized`, `Workspace Locked`), and the pre-session pages under `Pages/Auth`.
|
||||
- A page whose permission stories earn their own folder becomes one: `Pages/Settings/Billing/Overview` beside `Pages/Settings/Billing/Authz`. See **Permission stories** below.
|
||||
- Four levels is the floor to stay under: `Pages/Alerts/Channels/New` is as deep
|
||||
as it goes.
|
||||
- Pages nobody navigates to on purpose go under `Pages/System` (`Status`,
|
||||
`Unauthorized`, `Workspace Locked`), and the pre-session pages under
|
||||
`Pages/Auth`.
|
||||
- A page whose permission stories earn their own folder becomes one:
|
||||
`Pages/Settings/Billing/Overview` beside `Pages/Settings/Billing/Authz`. See
|
||||
**Permission stories** below.
|
||||
|
||||
**Order.** The `storySort.order` literal in `.storybook/preview.tsx` carries the order for every level. A new page in an existing area is appended to that area's array, in the order the product lists it; a new area goes where the side nav puts it. Storybook parses the order out of the file statically, so it has to stay an inline literal. Missing entries fall to the end of their level rather than disappearing, so a forgotten edit is a page at the bottom of its area, not a broken sidebar.
|
||||
**Order.** The `storySort.order` literal in `.storybook/preview.tsx` carries the
|
||||
order for every level. A new page in an existing area is appended to that area's
|
||||
array, in the order the product lists it; a new area goes where the side nav
|
||||
puts it. Storybook parses the order out of the file statically, so it has to
|
||||
stay an inline literal. Missing entries fall to the end of their level rather
|
||||
than disappearing, so a forgotten edit is a page at the bottom of its area, not
|
||||
a broken sidebar.
|
||||
|
||||
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag filter answers questions with. Only these:
|
||||
**Tags.** Declared on the meta, right under `title`, and what the sidebar's tag
|
||||
filter answers questions with. Only these:
|
||||
|
||||
| Tag | When |
|
||||
| --- | --- |
|
||||
@@ -45,7 +73,8 @@ The sidebar mirrors the app's own side nav (`container/SideNav/menuItems.tsx`),
|
||||
|
||||
`autodocs` comes from `preview.tsx` and is never written on a meta.
|
||||
|
||||
**Doc comment on the meta.** What the page is, in the page's own terms, then a blank line, then the route:
|
||||
**Doc comment on the meta.** What the page is, in the page's own terms, then a
|
||||
blank line, then the route:
|
||||
|
||||
```tsx
|
||||
const pageStory = storyMocks(logsExplorerMocks, {
|
||||
@@ -68,33 +97,79 @@ const meta = {
|
||||
} satisfies Meta<LogsExplorerArgs>;
|
||||
```
|
||||
|
||||
The `pageStory` const and the trailing `parameters` line are what make the doc comment safe. The comment compiles to a `parameters` property that the csf plugin appends after the spread, so a meta that spreads `storyMocks(...)` and stops there loses `parameters.signoz` and renders the page against the global handlers alone: every one of the page's endpoints misses. Restating `parameters` as a literal gives the plugin something to merge into. `resolveStory` logs the combination that says it happened, so the console names it rather than leaving it to be found by reading the page.
|
||||
The `pageStory` const and the trailing `parameters` line are what make the doc
|
||||
comment safe. The comment compiles to a `parameters` property that the csf plugin
|
||||
appends after the spread, so a meta that spreads `storyMocks(...)` and stops
|
||||
there loses `parameters.signoz` and renders the page against the global handlers
|
||||
alone: every one of the page's endpoints misses. Restating `parameters` as a
|
||||
literal gives the plugin something to merge into. `resolveStory` logs the
|
||||
combination that says it happened, so the console names it rather than leaving it
|
||||
to be found by reading the page.
|
||||
|
||||
It is the description on the page's Docs page, which is the only place a reader who is not in the code finds out what the page is for. Two or three sentences: what it shows, what drives it, and the gating worth knowing about (`Gated on authz permissions`, `follows the legacy editor role`). A control-driven route says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks which``.
|
||||
It is the description on the page's Docs page, which is the only place a reader
|
||||
who is not in the code finds out what the page is for. Two or three sentences:
|
||||
what it shows, what drives it, and the gating worth knowing about (`Gated on
|
||||
authz permissions`, `follows the legacy editor role`). A control-driven route
|
||||
says so instead of a path: ``Route: `/metrics-explorer/*`, the tab control picks
|
||||
which``.
|
||||
|
||||
## Permission stories
|
||||
|
||||
A page that gates UI on `lib/authz` keeps its permission states in a folder of their own, so the page's own file stays about the page and the sidebar answers "what does this permission do" in one place.
|
||||
A page that gates UI on `lib/authz` keeps its permission states in a folder of
|
||||
their own, so the page's own file stays about the page and the sidebar answers
|
||||
"what does this permission do" in one place.
|
||||
|
||||
**Layout.** A second story file at `stories/authz/<Page>.authz.stories.tsx`, titled `Pages/<Area>/<Page>/Authz`, which turns the page into a folder: its own file is retitled `Pages/<Area>/<Page>/Overview`, and `.storybook/preview.tsx` gains the sub-order (`'Billing', ['Overview', 'Authz']`). Both files carry the `authz` tag and share the page's one mocks module, which the authz file imports as `../<Page>.stories.mocks`. It declares no controls and no mock data of its own: a permission story that needs a new response is a control the page's mocks were missing.
|
||||
**Layout.** A second story file at `stories/authz/<Page>.authz.stories.tsx`,
|
||||
titled `Pages/<Area>/<Page>/Authz`, which turns the page into a folder: its own
|
||||
file is retitled `Pages/<Area>/<Page>/Overview`, and `.storybook/preview.tsx`
|
||||
gains the sub-order (`'Billing', ['Overview', 'Authz']`). Both files carry the
|
||||
`authz` tag and share the page's one mocks module, which the authz file imports
|
||||
as `../<Page>.stories.mocks`. It declares no controls and no mock data of its
|
||||
own: a permission story that needs a new response is a control the page's mocks
|
||||
were missing.
|
||||
|
||||
**One story per permission the page reads**, named for what is gone: `NoRead`, `NoList`, `NoUpdate`, `NoCreate`, `NoDelete`. Then the combinations the page itself distinguishes, and only those: `NoManage` where two permissions gate one button, `ReadOnly` where everything but reading is denied, `NoSubscriptionAccess` where none of the resource's permissions are held, and `CheckFailed` for `authzState: 'error'`, which is the page's fail-open path rather than a denial.
|
||||
**One story per permission the page reads**, named for what is gone: `NoRead`,
|
||||
`NoList`, `NoUpdate`, `NoCreate`, `NoDelete`. Then the combinations the page
|
||||
itself distinguishes, and only those: `NoManage` where two permissions gate one
|
||||
button, `ReadOnly` where everything but reading is denied, `NoSubscriptionAccess`
|
||||
where none of the resource's permissions are held, and `CheckFailed` for
|
||||
`authzState: 'error'`, which is the page's fail-open path rather than a denial.
|
||||
|
||||
**Revoke, never allow-list.** Each story is a full grant minus what its name says: `args: { revoked: ['read:subscription'] }`. The `Revoked` control subtracts from the preset, so the story stays "an admin missing one permission" as the catalogue grows, and the diff against the page's `Default` is the one permission. Rebuilding the allow-list by hand drifts the moment a resource is added.
|
||||
**Revoke, never allow-list.** Each story is a full grant minus what its name
|
||||
says: `args: { revoked: ['read:subscription'] }`. The `Revoked` control subtracts
|
||||
from the preset, so the story stays "an admin missing one permission" as the
|
||||
catalogue grows, and the diff against the page's `Default` is the one permission.
|
||||
Rebuilding the allow-list by hand drifts the moment a resource is added.
|
||||
|
||||
**Never a role preset in this folder.** `access: 'viewer'` moves the legacy role, the side nav and every other resource's permissions at the same time, so the story no longer shows what its name claims. A persona is a story on the page's own file, and only when the product has that persona.
|
||||
**Never a role preset in this folder.** `access: 'viewer'` moves the legacy role,
|
||||
the side nav and every other resource's permissions at the same time, so the
|
||||
story no longer shows what its name claims. A persona is a story on the page's
|
||||
own file, and only when the product has that persona.
|
||||
|
||||
**Pair the revocation with the state that renders the gated control.** A button that only exists on a trial needs the plan too: `args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission whose denial changes nothing on screen gets no story: say so in the PR.
|
||||
**Pair the revocation with the state that renders the gated control.** A button
|
||||
that only exists on a trial needs the plan too:
|
||||
`args: { plan: 'on-trial', revoked: ['create:subscription'] }`. A permission
|
||||
whose denial changes nothing on screen gets no story: say so in the PR.
|
||||
|
||||
Check these by what is turned off, not by what the page says. The page reads the same either way, so a story that is wrong still looks right. Check that the control the permission guards is off, and that the denial message is there or gone.
|
||||
Check these by what is turned off, not by what the page says. The page reads the
|
||||
same either way, so a story that is wrong still looks right. Check that the
|
||||
control the permission guards is off, and that the denial message is there or
|
||||
gone.
|
||||
|
||||
**Do not use `toBeEnabled` or `toBeDisabled`.** They only look at the `disabled` attribute. `@signozhq/ui` turns a button off with `aria-disabled` instead, so these matchers pass on a button nobody can click, and the test goes green while clicking straight through a denial. Check the attribute the component really sets:
|
||||
**Do not use `toBeEnabled` or `toBeDisabled`.** They only look at the `disabled`
|
||||
attribute. `@signozhq/ui` turns a button off with `aria-disabled` instead, so
|
||||
these matchers pass on a button nobody can click, and the test goes green while
|
||||
clicking straight through a denial. Check the attribute the component really
|
||||
sets:
|
||||
|
||||
```ts
|
||||
expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
```
|
||||
|
||||
**Some controls are off with no attribute at all.** The browser turns off everything inside a disabled `<fieldset>` without marking it, and a switch is a `<button>` underneath. For those, an attribute check proves nothing, and so does looking for a parent `fieldset[disabled]`. Click it and check nothing happened:
|
||||
**Some controls are off with no attribute at all.** The browser turns off
|
||||
everything inside a disabled `<fieldset>` without marking it, and a switch is a
|
||||
`<button>` underneath. For those, an attribute check proves nothing, and so does
|
||||
looking for a parent `fieldset[disabled]`. Click it and check nothing happened:
|
||||
|
||||
```ts
|
||||
const before = toggle.getAttribute('aria-checked');
|
||||
@@ -102,22 +177,66 @@ await userEvent.click(toggle);
|
||||
await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
```
|
||||
|
||||
**Check that a new assertion can fail.** Flip it once and run it. If that story goes red and nothing else does, the assertion is real; put it back. A test that cannot fail is worse than no test, because it looks like coverage.
|
||||
**Check that a new assertion can fail.** Flip it once and run it. If that story
|
||||
goes red and nothing else does, the assertion is real; put it back. A test that
|
||||
cannot fail is worse than no test, because it looks like coverage.
|
||||
|
||||
**Delete a story once its difference disappears.** Say a story checks that a button reads "View" rather than "Edit" when a permission is missing. If the label later becomes the same either way, that story passes no matter which permissions are held, including all of them, and it is testing nothing. When a behaviour stops depending on a permission, delete the story or rewrite it to lock in the new behaviour, and say which one you did in the PR.
|
||||
**Delete a story once its difference disappears.** Say a story checks that a
|
||||
button reads "View" rather than "Edit" when a permission is missing. If the label
|
||||
later becomes the same either way, that story passes no matter which permissions
|
||||
are held, including all of them, and it is testing nothing. When a behaviour
|
||||
stops depending on a permission, delete the story or rewrite it to lock in the
|
||||
new behaviour, and say which one you did in the PR.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args, every widget carrying data. Empty, loading and failed are variants or control values, never the default.
|
||||
- **A control is a knob on a response**, resolved through `handlers`, `config` or `effect`. Never a component prop, never a module mock added for one story.
|
||||
- **Every branch in the inventory is reachable from the panel.** A state that needs a code edit to see is a missing control.
|
||||
- **Never re-declare what every story already has**: banner, side nav, data state (loaded/loading/error), access preset, permissions, check state.
|
||||
- **A variant earns a story only when it is worth linking to**: an empty workspace, a viewer, a page mid-load. Everything else stays a control.
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control covers loaded, loading and failed in one declaration. Endpoints the page cannot render without (ingestion detection, preferences, feature payloads) take a plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param navigation works inside a story; a different pathname is blocked and reported by the overlay. A control for a param is worth it only when the param is a page mode someone would want to flip.
|
||||
- **File layout**: every story file for a page lives under `src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`, payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing page-specific in `src/storybook/controls/`. A page that is a tab strip over several routes gets one story file per tab, in its own folder under the module page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its own mocks and `__story_mockdata__/`; the builders more than one tab needs stay in the module page's own `stories/__story_mockdata__/` (`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as `../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab strip is there, and the `route` its mocks return decides which tab is open. A page's permission stories go one level further down, in `stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see **Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a control: a drawer, a modal, an edit mode the page holds in component state. Drive it with `userEvent` and the queries from `storybook/test`, take the first of a repeated row action, and wait on the state's own text. The page fetches before it renders a row, so the finder needs a timeout past the 1s default. A state the app drops again on its own, such as one keyed on an array identity that a refetch replaces, does not get a story: it would not survive being looked at. A _sequence_ of such states, a wizard's steps or a questionnaire's pages, is still a control: declare the steps in the mocks module and walk them from a `play` on the meta that destructures `mount`, which is what makes Storybook replay it on an arg change. See [references/controls.md](references/controls.md).
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
- **Default is the loaded page.** `export const Default: Story = {}` with no args,
|
||||
every widget carrying data. Empty, loading and failed are variants or control
|
||||
values, never the default.
|
||||
- **A control is a knob on a response**, resolved through `handlers`, `config` or
|
||||
`effect`. Never a component prop, never a module mock added for one story.
|
||||
- **Every branch in the inventory is reachable from the panel.** A state that
|
||||
needs a code edit to see is a missing control.
|
||||
- **Never re-declare what every story already has**: banner, side nav, data state
|
||||
(loaded/loading/error), access preset, permissions, check state.
|
||||
- **A variant earns a story only when it is worth linking to**: an empty
|
||||
workspace, a viewer, a page mid-load. Everything else stays a control.
|
||||
- **Endpoints the page owns go through `response.json`**, so the Data control
|
||||
covers loaded, loading and failed in one declaration. Endpoints the page cannot
|
||||
render without (ingestion detection, preferences, feature payloads) take a
|
||||
plain resolver so the shell survives the loading and error states.
|
||||
- **Query-param state starts from `route`** (`/logs?tab=explorer`). In-page param
|
||||
navigation works inside a story; a different pathname is blocked and reported
|
||||
by the overlay. A control for a param is worth it only when the param is a page
|
||||
mode someone would want to flip.
|
||||
- **File layout**: every story file for a page lives under
|
||||
`src/pages/<Page>/stories/`: `<Page>.stories.tsx`, `<Page>.stories.mocks.tsx`,
|
||||
payload builders in `stories/__story_mockdata__/<page>.ts`. Nothing
|
||||
page-specific in `src/storybook/controls/`. A page that is a tab strip over
|
||||
several routes gets one story file per tab, in its own folder under the module
|
||||
page (`LogsModulePage/Pipelines/stories/Pipelines.stories.tsx`), each with its
|
||||
own mocks and `__story_mockdata__/`; the builders more than one tab needs stay
|
||||
in the module page's own `stories/__story_mockdata__/`
|
||||
(`AlertList/stories/__story_mockdata__/alerts.ts`), which a tab reaches as
|
||||
`../../stories/__story_mockdata__/alerts`. Every one of them renders the module page, so the tab
|
||||
strip is there, and the `route` its mocks return decides which tab is open.
|
||||
A page's permission stories go one level further down, in
|
||||
`stories/authz/<Page>.authz.stories.tsx`, on the page's own mocks: see
|
||||
**Permission stories**.
|
||||
- **A state only a click reaches is a story with a `play` function**, not a
|
||||
control: a drawer, a modal, an edit mode the page holds in component state.
|
||||
Drive it with `userEvent` and the queries from `storybook/test`, take the first
|
||||
of a repeated row action, and wait on the state's own text. The page fetches
|
||||
before it renders a row, so the finder needs a timeout past the 1s default. A
|
||||
state the app drops again on its own, such as one keyed on an array identity
|
||||
that a refetch replaces, does not get a story: it would not survive being
|
||||
looked at. A *sequence* of such states, a wizard's steps or a
|
||||
questionnaire's pages, is still a control: declare the steps in the mocks
|
||||
module and walk them from a `play` on the meta that destructures `mount`, which
|
||||
is what makes Storybook replay it on an arg change. See
|
||||
[references/controls.md](references/controls.md).
|
||||
- **The mocks are AI-owned and say so.** `<Page>.stories.mocks.tsx` and every file
|
||||
under a `__story_mockdata__/` open with this banner, above the imports:
|
||||
|
||||
```ts
|
||||
/**
|
||||
@@ -126,23 +245,52 @@ await expect(toggle.getAttribute('aria-checked')).toBe(before);
|
||||
*/
|
||||
```
|
||||
|
||||
The root `.gitattributes` marks both paths `linguist-generated=true`, so the reviewer gets them collapsed and spends the attention on the rendered page. The story file is the human surface and never carries the banner. A file with the banner has to stay regenerable from the page alone: no page knowledge in `src/storybook/`, and builders typed from `src/api/generated` where the endpoint has types, so a contract change is a compile error instead of a mock that lies.
|
||||
|
||||
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
|
||||
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before writing a response shape inline, check if a builder exists; if not and the shape will repeat, add it there. Page-specific builders stay in the page's `__story_mockdata__/`.
|
||||
- **The story's own doc comment is per state.** Every `export const` gets one: what that state shows, not how it is built. It renders in the States list on the page's Docs page, so `Undocumented.` there is a story nobody described.
|
||||
- **Story names come from a fixed vocabulary** where one fits: `Default`, `Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those (`ViewerAccess`, `NonAdmin`).
|
||||
- **No comment is the default.** Write one only for what the code cannot show: a shape the backend dictates, an app bug the mock reproduces, an ordering or cap the page depends on, a workaround and the reason for it. Never restate a name, a type, or what a builder plainly builds; if the sentence reads as the signature in prose, delete it. Nothing addressed to a reviewer. The one comment a story always gets is its own doc comment: what it shows, in the page's own terms.
|
||||
The root `.gitattributes` marks both paths `linguist-generated=true`, so the
|
||||
reviewer gets them collapsed and spends the attention on the rendered page. The
|
||||
story file is the human surface and never carries the banner. A file with the
|
||||
banner has to stay regenerable from the page alone: no page knowledge in
|
||||
`src/storybook/`, and builders typed from `src/api/generated` where the endpoint
|
||||
has types, so a contract change is a compile error instead of a mock that lies.
|
||||
- **Reuse fixtures** from `src/mocks-server/` and `src/tests/fixtures/` where they
|
||||
exist. An endpoint jest needs too belongs in `src/mocks-server/handlers.ts`.
|
||||
- **Shared response builders live in `src/storybook/msw/__story_mockdata__/`**: typed
|
||||
helpers like `queryRangeV5ScalarResponse` that multiple pages need. Before
|
||||
writing a response shape inline, check if a builder exists; if not and the
|
||||
shape will repeat, add it there. Page-specific builders stay in the page's
|
||||
`__story_mockdata__/`.
|
||||
- **The story's own doc comment is per state.** Every `export const` gets one:
|
||||
what that state shows, not how it is built. It renders in the States list on
|
||||
the page's Docs page, so `Undocumented.` there is a story nobody described.
|
||||
- **Story names come from a fixed vocabulary** where one fits: `Default`,
|
||||
`Viewer`, `Empty`, `Loading`, `Error`. Page-specific states get page-specific
|
||||
names (`NoIngestion`, `Unlicensed`), never a second spelling of one of those
|
||||
(`ViewerAccess`, `NonAdmin`).
|
||||
- **No comment is the default.** Write one only for what the code cannot show:
|
||||
a shape the backend dictates, an app bug the mock reproduces, an ordering or
|
||||
cap the page depends on, a workaround and the reason for it. Never restate a
|
||||
name, a type, or what a builder plainly builds; if the sentence reads as the
|
||||
signature in prose, delete it. Nothing addressed to a reviewer. The one
|
||||
comment a story always gets is its own doc comment: what it shows, in the
|
||||
page's own terms.
|
||||
|
||||
## Done means
|
||||
|
||||
- [ ] `Default` shows the page with data, checked in dark and light
|
||||
- [ ] title follows the sidebar rules, tags declared, and the page's entry added to the `storySort.order` literal in `.storybook/preview.tsx`
|
||||
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates `parameters: { ...pageStory.parameters }` after the spread, and every story export carries its own doc comment
|
||||
- [ ] the page's Docs page renders: description, controls table, and one row per state with no `Undocumented.`
|
||||
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it reads, each reached by `revoked`, none of them a role preset, and each one checked by the `disabled` state of what the permission gates
|
||||
- [ ] title follows the sidebar rules, tags declared, and the page's entry added
|
||||
to the `storySort.order` literal in `.storybook/preview.tsx`
|
||||
- [ ] the meta carries its doc comment with the `Route:` line, the meta restates
|
||||
`parameters: { ...pageStory.parameters }` after the spread, and every story
|
||||
export carries its own doc comment
|
||||
- [ ] the page's Docs page renders: description, controls table, and one row per
|
||||
state with no `Undocumented.`
|
||||
- [ ] a page tagged `authz` has its `Authz` folder: one story per permission it
|
||||
reads, each reached by `revoked`, none of them a role preset, and each one
|
||||
checked by the `disabled` state of what the permission gates
|
||||
- [ ] the mocks module and every `__story_mockdata__` file carry the AI-owned banner
|
||||
- [ ] every control flipped once, its effect seen on screen
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled request, no React warning
|
||||
- [ ] console clean: no `[storybook] no msw handler`, no 501, no msw unhandled
|
||||
request, no React warning
|
||||
- [ ] no navigation overlay on mount
|
||||
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`, `pnpm exec oxfmt --check <files>` all clean. The repo has no prettier: `pnpm exec prettier` prints a pass while exiting 254
|
||||
- [ ] `pnpm tsgo --noEmit`, `pnpm exec oxlint <files>`,
|
||||
`pnpm exec oxfmt --check <files>` all clean. The repo has no
|
||||
prettier: `pnpm exec prettier` prints a pass while exiting 254
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Turning the inventory into controls
|
||||
|
||||
Every row of the inventory becomes a control, a global control that already exists, or a documented reason it cannot be one.
|
||||
Every row of the inventory becomes a control, a global control that already
|
||||
exists, or a documented reason it cannot be one.
|
||||
|
||||
## Imports
|
||||
|
||||
Paths written as `src/storybook/...` in prose are repo paths, not import specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules inside `src/storybook/` import each other relatively.
|
||||
Paths written as `src/storybook/...` in prose are repo paths, not import
|
||||
specifiers. Stories import through the `@/` alias (`@/*` → `./src/*`); modules
|
||||
inside `src/storybook/` import each other relatively.
|
||||
|
||||
| Import | From |
|
||||
| --- | --- |
|
||||
@@ -28,21 +31,38 @@ Paths written as `src/storybook/...` in prose are repo paths, not import specifi
|
||||
|
||||
Rules that come with them:
|
||||
|
||||
- `countControl` `max` goes past what the page renders, so a story can show the cap being hit. `0` is the empty state, which is why an empty list rarely needs its own story. When the cap is in the _request_ (`?limit=5`) rather than the renderer, stop `max` at the limit: a longer response is a body the backend cannot send.
|
||||
- `choiceControl` options come from a `const` array typed with `(typeof X)[number]`, not from string literals scattered in the handlers.
|
||||
- Defaults describe the fully-populated page. The panel starts where `Default` starts.
|
||||
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or `'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per control.
|
||||
- `description` only when the name does not carry the effect (what dismissing does, what the cap is, which widget it feeds).
|
||||
- `countControl` `max` goes past what the page renders, so a story can show the
|
||||
cap being hit. `0` is the empty state, which is why an empty list rarely needs
|
||||
its own story. When the cap is in the *request* (`?limit=5`) rather than the
|
||||
renderer, stop `max` at the limit: a longer response is a body the backend
|
||||
cannot send.
|
||||
- `choiceControl` options come from a `const` array typed with
|
||||
`(typeof X)[number]`, not from string literals scattered in the handlers.
|
||||
- Defaults describe the fully-populated page. The panel starts where `Default`
|
||||
starts.
|
||||
- `group` is `'<Page> · <facet>'`, such as `'Services · lists'` or
|
||||
`'Alerts · rules'`. Keep a page's knobs in two or three groups, not one per
|
||||
control.
|
||||
- `description` only when the name does not carry the effect (what dismissing
|
||||
does, what the cap is, which widget it feeds).
|
||||
|
||||
## Which hook
|
||||
|
||||
`defineStoryMocks` takes three, all optional:
|
||||
|
||||
- `handlers(values, response)`: the page's endpoints. Everything the page owns goes through `response.json`, so the global Data control turns the whole page into loading or failed without a second declaration. An endpoint the page cannot render at all without (ingestion detection, preferences, license payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays visible while the rest hangs or fails.
|
||||
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`, `appContext`, `reduxState`, `queryBuilder`, `theme`.
|
||||
- `handlers(values, response)`: the page's endpoints. Everything the page owns
|
||||
goes through `response.json`, so the global Data control turns the whole page
|
||||
into loading or failed without a second declaration. An endpoint the page
|
||||
cannot render at all without (ingestion detection, preferences, license
|
||||
payloads) takes a plain `rest.get(...)` resolver instead, so the shell stays
|
||||
visible while the rest hangs or fails.
|
||||
- `config(values)`: `SignozStoryConfig` for knobs no endpoint covers: `route`,
|
||||
`appContext`, `reduxState`, `queryBuilder`, `theme`.
|
||||
- `effect(values)`: module-level state no provider exposes.
|
||||
|
||||
One endpoint feeding several widgets stays one handler that reads the request. `response.json` hands the request to the builder and awaits it, so reading a query param, or a POST body, does not cost the Data control:
|
||||
One endpoint feeding several widgets stays one handler that reads the request.
|
||||
`response.json` hands the request to the builder and awaits it, so reading a
|
||||
query param, or a POST body, does not cost the Data control:
|
||||
|
||||
```ts
|
||||
rest.get(
|
||||
@@ -65,11 +85,18 @@ rest.post(
|
||||
),
|
||||
```
|
||||
|
||||
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the endpoint has to keep answering while the Data control is on `loading` or `error`: detection calls the page cannot render without.
|
||||
Reach for a plain `rest.post(url, async (req, res, ctx) => …)` only when the
|
||||
endpoint has to keep answering while the Data control is on `loading` or
|
||||
`error`: detection calls the page cannot render without.
|
||||
|
||||
## Mutations
|
||||
|
||||
A control drives the response, so a write the page makes against state a control owns does not stick: the refetch answers with the control's value and the button appears to do nothing. Two honest options: leave it declarative and say so in the PR, or move the state into `effect` so the handler can read what the page wrote. Never fake the write by mutating a builder's module state without saying where the state lives.
|
||||
A control drives the response, so a write the page makes against state a control
|
||||
owns does not stick: the refetch answers with the control's value and the button
|
||||
appears to do nothing. Two honest options: leave it declarative and say so in
|
||||
the PR, or move the state into `effect` so the handler can read what the page
|
||||
wrote. Never fake the write by mutating a builder's module state without saying
|
||||
where the state lives.
|
||||
|
||||
## Wiring it up
|
||||
|
||||
@@ -119,11 +146,15 @@ const meta = {
|
||||
} satisfies Meta<ServicesArgs>;
|
||||
```
|
||||
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set `access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
`PageStoryArgs` folds in the global controls, so a story's `args` can set
|
||||
`access`, `dataState` or `banner` next to the page's own knobs and stay typed.
|
||||
|
||||
## A step the page keeps in component state
|
||||
|
||||
A wizard's step, a questionnaire's page, a picker's next question: the page holds it in `useState` and nothing in the URL says which one is open. It is still a control. Declare the steps in the mocks module and drive them from a `play` on the **meta**, so every story of the page inherits the walk and only sets `args`:
|
||||
A wizard's step, a questionnaire's page, a picker's next question: the page holds
|
||||
it in `useState` and nothing in the URL says which one is open. It is still a
|
||||
control. Declare the steps in the mocks module and drive them from a `play` on
|
||||
the **meta**, so every story of the page inherits the walk and only sets `args`:
|
||||
|
||||
```tsx
|
||||
// <Page>.stories.mocks.tsx
|
||||
@@ -148,23 +179,39 @@ const meta = {
|
||||
export const Configure: Story = { args: { step: 'configure' } };
|
||||
```
|
||||
|
||||
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play function on an arg change only for a story whose play asks to be remounted (`usesMount`); otherwise it re-renders the tree the previous walk left behind and the panel looks broken. With `mount` destructured, the story renders when `play` calls it, and every arg change replays the walk from a fresh mount.
|
||||
**Destructuring `mount` is what makes it a control.** Storybook re-runs a play
|
||||
function on an arg change only for a story whose play asks to be remounted
|
||||
(`usesMount`); otherwise it re-renders the tree the previous walk left behind and
|
||||
the panel looks broken. With `mount` destructured, the story renders when `play`
|
||||
calls it, and every arg change replays the walk from a fresh mount.
|
||||
|
||||
The walk itself:
|
||||
|
||||
- one `answer` function per step, in an array indexed the same as the step list, so reaching step _n_ is `answers.slice(0, STEPS.indexOf(step))`;
|
||||
- answer each step with the least its Next button accepts, and prefer a "do this later" over filling a slider;
|
||||
- run them sequentially (`reduce` over a promise), since each answer is what renders the step the next one reads;
|
||||
- bail out when the page did not start where the walk expects, such as a source deep-linked past the questions. Check for the first step's own text rather than reading another control's value.
|
||||
- one `answer` function per step, in an array indexed the same as the step list,
|
||||
so reaching step *n* is `answers.slice(0, STEPS.indexOf(step))`;
|
||||
- answer each step with the least its Next button accepts, and prefer a "do this
|
||||
later" over filling a slider;
|
||||
- run them sequentially (`reduce` over a promise), since each answer is what
|
||||
renders the step the next one reads;
|
||||
- bail out when the page did not start where the walk expects, such as a source
|
||||
deep-linked past the questions. Check for the first step's own text rather than
|
||||
reading another control's value.
|
||||
|
||||
An endpoint that only settles the transition between two steps (the profile a questionnaire saves before its last page) takes a plain resolver, or the Data control on `loading` strands the walk halfway.
|
||||
An endpoint that only settles the transition between two steps (the profile a
|
||||
questionnaire saves before its last page) takes a plain resolver, or the Data
|
||||
control on `loading` strands the walk halfway.
|
||||
|
||||
## Not a control
|
||||
|
||||
- Anything the global controls already cover: banner, side nav, data state, access preset, granted permissions, revoked permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`, `viewer`), and the builder turns intent into the payload.
|
||||
- Anything that needs a module mock or a component prop to work. If the state cannot be produced from a response, config or module state, say so in the PR instead of faking it.
|
||||
- Anything the global controls already cover: banner, side nav, data state,
|
||||
access preset, granted permissions, revoked permissions, check state.
|
||||
- A knob whose effect nobody can see on the page. Delete it or find the widget it
|
||||
was supposed to drive.
|
||||
- A raw payload as an object control. Controls carry intent (`5 dashboards`,
|
||||
`viewer`), and the builder turns intent into the payload.
|
||||
- Anything that needs a module mock or a component prop to work. If the state
|
||||
cannot be produced from a response, config or module state, say so in the PR
|
||||
instead of faking it.
|
||||
|
||||
## Control or story
|
||||
|
||||
@@ -173,8 +220,12 @@ Default to a control. Write a story when the state is worth a link:
|
||||
- the fresh workspace, because that is what a new user sees
|
||||
- a page-defining mode (a tab, a category) that has its own layout
|
||||
|
||||
A permission that visibly changes the page is a story too, but it goes in the page's `Authz` folder, one per permission, turned with the `Revoked` control. See **Permission stories** in SKILL.md.
|
||||
A permission that visibly changes the page is a story too, but it goes in the
|
||||
page's `Authz` folder, one per permission, turned with the `Revoked` control.
|
||||
See **Permission stories** in SKILL.md.
|
||||
|
||||
Combinations of controls do not need stories, which is what the panel is for.
|
||||
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms. Everywhere else the comment rule in SKILL.md applies: write one only for what the code cannot show.
|
||||
Each story gets one prose doc comment: what it shows, in the page's own terms.
|
||||
Everywhere else the comment rule in SKILL.md applies: write one only for what
|
||||
the code cannot show.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# Mapping a page
|
||||
|
||||
Two passes: read the code, then let the running story correct you. Write the inventory down: it is what the controls are derived from, and the only protection against a story that renders one state and calls it a page.
|
||||
Two passes: read the code, then let the running story correct you. Write the
|
||||
inventory down: it is what the controls are derived from, and the only
|
||||
protection against a story that renders one state and calls it a page.
|
||||
|
||||
## Pass 1: read the page
|
||||
|
||||
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts (`src/container/<Feature>/`), the hooks those use, the components with their own fetches. Stop at leaf components that take props only.
|
||||
Start at `src/pages/<Page>/` and follow it outward: the containers it mounts
|
||||
(`src/container/<Feature>/`), the hooks those use, the components with their own
|
||||
fetches. Stop at leaf components that take props only.
|
||||
|
||||
Grep recipes, run against the page's directories:
|
||||
|
||||
@@ -20,32 +24,41 @@ Grep recipes, run against the page's directories:
|
||||
| empty and error branches | `isLoading\|isError\|isFetching\|length === 0\|!data` |
|
||||
| render caps | `slice(0,\|PAGE_SIZE\|pageSize\|limit` |
|
||||
|
||||
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names, `src/lib/authz/README.md` how a permission check resolves.
|
||||
`src/constants/routes.ts` has the route, `src/constants/query.ts` the param names,
|
||||
`src/lib/authz/README.md` how a permission check resolves.
|
||||
|
||||
## The inventory
|
||||
|
||||
One table, in the story's PR or scratch notes:
|
||||
|
||||
| Endpoint | Feeds | States it can be in |
|
||||
| --------------- | ---------------- | ---------------------- |
|
||||
| Endpoint | Feeds | States it can be in |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/x` | the header count | populated, zero, error |
|
||||
|
||||
Plus four short lists:
|
||||
|
||||
- **Query params** the page reads, and what each one switches.
|
||||
- **Permission checks** the page makes, and what disappears when each is denied.
|
||||
- **Preferences and flags** that change layout (dismissed banners, onboarding checklists, opt-in views).
|
||||
- **Preferences and flags** that change layout (dismissed banners, onboarding
|
||||
checklists, opt-in views).
|
||||
- **Caps**: how many rows each list renders before it truncates or paginates.
|
||||
|
||||
A state that appears in this inventory and not in the controls panel is a bug in the story.
|
||||
A state that appears in this inventory and not in the controls panel is a bug in
|
||||
the story.
|
||||
|
||||
## Pass 2: let it run
|
||||
|
||||
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the route with `layout: 'app'`, then open it (see verify.md). The console is the oracle:
|
||||
Write the story and an empty `defineStoryMocks({ controls: {} })`, point it at the
|
||||
route with `layout: 'app'`, then open it (see verify.md). The console is the
|
||||
oracle:
|
||||
|
||||
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1 missed. Add it to the inventory.
|
||||
- an msw unhandled-request warning: a request going to an origin the handlers do not answer on. handlers are declared against `http://localhost`.
|
||||
- a spinner that never resolves with the Data control on `loaded`: a handler whose URL does not match what the page calls.
|
||||
- the navigation overlay on mount: the page redirects, usually because `route` is wrong or a guard is failing on a permission the controls have not granted.
|
||||
- `[storybook] no msw handler` or a 501 from the catch-all: an endpoint pass 1
|
||||
missed. Add it to the inventory.
|
||||
- an msw unhandled-request warning: a request going to an origin the handlers do
|
||||
not answer on. handlers are declared against `http://localhost`.
|
||||
- a spinner that never resolves with the Data control on `loaded`: a handler
|
||||
whose URL does not match what the page calls.
|
||||
- the navigation overlay on mount: the page redirects, usually because `route`
|
||||
is wrong or a guard is failing on a permission the controls have not granted.
|
||||
|
||||
Repeat until the console is silent. Only then start declaring controls.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Verifying a page story
|
||||
|
||||
A story is not done because it compiles. It is done when each control has been seen changing the page and the console is silent.
|
||||
A story is not done because it compiles. It is done when each control has been
|
||||
seen changing the page and the console is silent.
|
||||
|
||||
## Run it
|
||||
|
||||
@@ -8,9 +9,12 @@ A story is not done because it compiles. It is done when each control has been s
|
||||
cd frontend && pnpm storybook --ci --quiet # :6006, background it
|
||||
```
|
||||
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an already-running server; an empty first poll is not a broken `stories` glob.
|
||||
A newly added `.stories.tsx` takes a few seconds to appear in `index.json` on an
|
||||
already-running server; an empty first poll is not a broken `stories` glob.
|
||||
|
||||
Story ids come from the meta title: `Pages/Services/List` → `pages-services-list`, plus the story export in kebab-case. Render one story on its own:
|
||||
Story ids come from the meta title: `Pages/Services/List` →
|
||||
`pages-services-list`, plus the story export in kebab-case. Render one story on
|
||||
its own:
|
||||
|
||||
```
|
||||
http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
|
||||
@@ -18,7 +22,9 @@ http://localhost:6006/iframe.html?id=pages-services-list--default&viewMode=story
|
||||
|
||||
## Flip controls from the URL
|
||||
|
||||
Args are settable in the iframe URL, so a whole sweep runs headless without touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays indexed, several separated by `;`, and the theme through `globals`:
|
||||
Args are settable in the iframe URL, so a whole sweep runs headless without
|
||||
touching the panel. Booleans go as `!true` / `!false`, numbers bare, arrays
|
||||
indexed, several separated by `;`, and the theme through `globals`:
|
||||
|
||||
```
|
||||
&args=services:0;apdex:poor;access:viewer;dataState:loading
|
||||
@@ -26,11 +32,13 @@ Args are settable in the iframe URL, so a whole sweep runs headless without touc
|
||||
&globals=theme:light
|
||||
```
|
||||
|
||||
That is the cheap way to check a control does something: load with and without it, diff the page text.
|
||||
That is the cheap way to check a control does something: load with and without
|
||||
it, diff the page text.
|
||||
|
||||
## Drive it
|
||||
|
||||
Playwright lives in the repo's e2e workspace, so a scratch script can use it directly:
|
||||
Playwright lives in the repo's e2e workspace, so a scratch script can use it
|
||||
directly:
|
||||
|
||||
```js
|
||||
import pw from '<repo>/tests/e2e/node_modules/playwright/index.js';
|
||||
@@ -50,18 +58,40 @@ console.log((await page.locator('body').innerText()).slice(0, 1500), problems);
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
That is one run, for checking the story you just wrote. Comparing two branches is a different job: see the `sbshot-visual-diff` skill, which covers the baseline to pick, measuring the noise and exporting something a reviewer can open.
|
||||
That is one run, for checking the story you just wrote. Comparing two branches is
|
||||
a different job: see the `sbshot-visual-diff` skill, which covers the baseline to
|
||||
pick, measuring the noise and exporting something a reviewer can open.
|
||||
|
||||
## Gates
|
||||
|
||||
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an msw unhandled-request warning, a React key or state warning: assume the story is wrong first. A warning that survives is sometimes the app's. Prove it by turning off the control that renders the widget and watching the warning go with it, and by finding the same component elsewhere doing it right. Then report the app bug in the PR. Never invent a field the API does not return to silence a warning.
|
||||
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load means the page is trying to leave: wrong `route`, or a guard denying on a permission the controls did not grant.
|
||||
- **Every control moves something.** Sweep them one at a time from the URL and diff the page text. A control with no diff is either wired to nothing or aimed at a widget that is not rendering. Some only show their effect after an interaction, such as a tab that has to be clicked or a select that has to be opened. Drive that interaction rather than calling the control unobservable.
|
||||
- **Both themes render styled.** An unstyled page means the story is not inside the provider decorator, or `<body data-theme>` was lost.
|
||||
- **Roles agree.** `<body data-signoz-story-role>` and `<body data-signoz-context-role>` disagreeing means the page reads a different `AppContext` than the story config fills.
|
||||
- **The page's own navigation works.** Tabs, filters and pagination that write query params should re-render the page in place; only leaving the page belongs in the overlay.
|
||||
- **Console silent.** `[storybook] no msw handler`, a 501 from the catch-all, an
|
||||
msw unhandled-request warning, a React key or state warning: assume the story
|
||||
is wrong first. A warning that survives is sometimes the app's. Prove it by
|
||||
turning off the control that renders the widget and watching the warning go
|
||||
with it, and by finding the same component elsewhere doing it right. Then
|
||||
report the app bug in the PR. Never invent a field the API does not return to
|
||||
silence a warning.
|
||||
- **No navigation overlay on mount.** "Navigation blocked in Storybook" on load
|
||||
means the page is trying to leave: wrong `route`, or a guard denying on a
|
||||
permission the controls did not grant.
|
||||
- **Every control moves something.** Sweep them one at a time from the URL and
|
||||
diff the page text. A control with no diff is either wired to nothing or aimed
|
||||
at a widget that is not rendering. Some only show their effect after an
|
||||
interaction, such as a tab that has to be clicked or a select that has to be
|
||||
opened. Drive that interaction rather than calling the control unobservable.
|
||||
- **Both themes render styled.** An unstyled page means the story is not inside
|
||||
the provider decorator, or `<body data-theme>` was lost.
|
||||
- **Roles agree.** `<body data-signoz-story-role>` and
|
||||
`<body data-signoz-context-role>` disagreeing means the page reads a different
|
||||
`AppContext` than the story config fills.
|
||||
- **The page's own navigation works.** Tabs, filters and pagination that write
|
||||
query params should re-render the page in place; only leaving the page belongs
|
||||
in the overlay.
|
||||
|
||||
## Then the usual
|
||||
|
||||
@@ -71,7 +101,9 @@ pnpm exec oxlint <changed files>
|
||||
pnpm exec oxfmt --check <changed files>
|
||||
```
|
||||
|
||||
There is no prettier in this repo. `pnpm exec prettier --check` fetches something else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is not one.
|
||||
There is no prettier in this repo. `pnpm exec prettier --check` fetches something
|
||||
else, prints `Prettier: All files formatted correctly` and exits 254: a pass that is
|
||||
not one.
|
||||
|
||||
## Common failures
|
||||
|
||||
|
||||
8
.github/CODEOWNERS
vendored
@@ -290,14 +290,6 @@ go.mod @therealpandey
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
## Saved Views
|
||||
/frontend/src/container/SavedViews/ @aks07
|
||||
/frontend/src/container/Home/SavedViews/ @aks07
|
||||
/frontend/src/hooks/saveViews/ @aks07
|
||||
|
||||
## Bottom Strip
|
||||
/frontend/src/container/BottomStrip/ @aks07
|
||||
|
||||
## Storybook
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
|
||||
@@ -88,7 +88,10 @@ tests/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
│ ├── routing.ts
|
||||
│ ├── dashboards.ts # V2 seeding + query_range mocks; panels → panel-editor → panel-config extend it
|
||||
│ ├── panels.ts
|
||||
│ └── panel-kinds.ts # one row per panel kind; drives tests/dashboards/v2/contract
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -128,7 +131,8 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
|
||||
- Drivers layer: `drivers/panels.ts` exports `withPanels(dashboardsTest)` and re-exports `drivers/dashboards.ts`, so a spec still imports one module.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { usePlotContext } from '../../context/PlotContext';
|
||||
import { UPlotChartProps } from '../types';
|
||||
import { prepareAlignedData } from './utils';
|
||||
import { prepareAlignedData, setInstanceHandle } from './utils';
|
||||
|
||||
/**
|
||||
* Check if dimensions have changed
|
||||
@@ -58,6 +58,7 @@ export default function UPlotChart({
|
||||
onDestroy?.(plotInstanceRef.current);
|
||||
plotInstanceRef.current.destroy();
|
||||
plotInstanceRef.current = null;
|
||||
setInstanceHandle(containerRef.current, null);
|
||||
setPlotContextInitialState({ uPlotInstance: null });
|
||||
plotRef?.(null);
|
||||
}
|
||||
@@ -101,6 +102,7 @@ export default function UPlotChart({
|
||||
});
|
||||
|
||||
plotInstanceRef.current = plot;
|
||||
setInstanceHandle(containerRef.current, plot);
|
||||
}, [
|
||||
config,
|
||||
data,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { AlignedData } from 'uplot';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
export function prepareAlignedData({
|
||||
data,
|
||||
@@ -14,3 +14,33 @@ export function prepareAlignedData({
|
||||
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
|
||||
: (data as AlignedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live uPlot instance, hung off its own container element.
|
||||
*
|
||||
* Charts render to a canvas, so from the outside there is nothing to assert on
|
||||
* beyond pixels — an E2E test can see that a chart exists but not that a series
|
||||
* was hidden, an axis bound applied, or a line turned dashed. Exposing the
|
||||
* instance on its container gives those tests (and anyone debugging in the
|
||||
* console) the real chart state to read.
|
||||
*
|
||||
* On the container rather than a global: it is naturally scoped per panel, it
|
||||
* needs no registry keyed by id, and it is collected along with the node.
|
||||
*/
|
||||
export interface UPlotHostElement extends HTMLDivElement {
|
||||
__uplot?: uPlot;
|
||||
}
|
||||
|
||||
export function setInstanceHandle(
|
||||
node: HTMLDivElement | null,
|
||||
plot: uPlot | null,
|
||||
): void {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (plot) {
|
||||
(node as UPlotHostElement).__uplot = plot;
|
||||
} else {
|
||||
delete (node as UPlotHostElement).__uplot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function AddColumnDropdown({
|
||||
value={field.name}
|
||||
isSelected={selectedNames.has(field.name)}
|
||||
onSelect={(): void => onToggle(field)}
|
||||
data-testid="list-columns-suggestion"
|
||||
data-testid={`list-columns-suggestion-${field.name}`}
|
||||
>
|
||||
{field.name}
|
||||
</ComboboxItem>
|
||||
|
||||
@@ -37,7 +37,12 @@ function SortableColumnChip({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={styles.chip}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.chip}
|
||||
data-testid={`list-column-chip-${name}`}
|
||||
>
|
||||
<span {...listeners} className={styles.grip}>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -61,7 +66,7 @@ function SortableColumnChip({
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={`Remove ${name}`}
|
||||
testId="list-column-remove"
|
||||
testId={`list-column-remove-${name}`}
|
||||
onClick={(): void => onRemove(name)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -88,6 +88,7 @@ function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
data-testid="preview-pane"
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ResizableHeaderProps extends Omit<
|
||||
> {
|
||||
width?: number;
|
||||
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
|
||||
/** Column key, used only to give the drag grip a stable test handle. */
|
||||
columnKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,7 @@ export interface ResizableHeaderProps extends Omit<
|
||||
function ResizableHeader({
|
||||
width,
|
||||
onResize,
|
||||
columnKey,
|
||||
...restProps
|
||||
}: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
@@ -34,13 +37,14 @@ function ResizableHeader({
|
||||
<span
|
||||
className={styles.handle}
|
||||
role="presentation"
|
||||
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
|
||||
// Stop the grip's click from reaching the column sorter underneath.
|
||||
// The grip is a pointer-only resize affordance, not keyboard-actionable.
|
||||
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[columnKey],
|
||||
);
|
||||
|
||||
if (!width || !onResize) {
|
||||
@@ -64,6 +68,7 @@ function ResizableHeader({
|
||||
ResizableHeader.defaultProps = {
|
||||
width: undefined,
|
||||
onResize: undefined,
|
||||
columnKey: undefined,
|
||||
};
|
||||
|
||||
export default ResizableHeader;
|
||||
|
||||
@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
|
||||
onHeaderCell: (): ResizableHeaderProps => ({
|
||||
width,
|
||||
onResize: key && width ? handleResize(key) : undefined,
|
||||
columnKey: key,
|
||||
}),
|
||||
} as Column<T>;
|
||||
}),
|
||||
|
||||
@@ -104,7 +104,9 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.header, 'panel-drag-handle')}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
|
||||
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
|
||||
{name}
|
||||
</Typography.Text>
|
||||
{description && (
|
||||
<Tooltip title={description}>
|
||||
<Info
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
await screen.findAllByTestId('list-columns-suggestion');
|
||||
await screen.findAllByTestId(/^list-columns-suggestion-/);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import anselmEickhoffUrl from '@/assets/Images/users/anselm.jpg';
|
||||
import danielSchellUrl from '@/assets/Images/users/daniel.webp';
|
||||
import dhruvGargUrl from '@/assets/Images/users/dhruv-garg.webp';
|
||||
import goFrendiGunawanUrl from '@/assets/Images/users/go-frendi.webp';
|
||||
import pranayNarangUrl from '@/assets/Images/users/pranay-narang.webp';
|
||||
import sheheryarSewaniUrl from '@/assets/Images/users/shey.webp';
|
||||
import subomiOluwalanaUrl from '@/assets/Images/users/subomi-oluwalana.webp';
|
||||
import vivekBhaktaUrl from '@/assets/Images/users/vivek-bhakta.webp';
|
||||
|
||||
export const infoData = [
|
||||
{
|
||||
id: 'infoBlock-1',
|
||||
@@ -52,7 +43,7 @@ export const enterpriseGradeValuesData = [
|
||||
export const customerStoriesData = [
|
||||
{
|
||||
key: 'story-subomi-oluwalana',
|
||||
avatar: subomiOluwalanaUrl,
|
||||
avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp',
|
||||
personName: 'Subomi Oluwalana',
|
||||
role: 'Founder & CEO at Convoy',
|
||||
customerName: 'Convoy',
|
||||
@@ -63,7 +54,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-dhruv-garg',
|
||||
avatar: dhruvGargUrl,
|
||||
avatar: 'https://signoz.io/img/users/dhruv-garg.webp',
|
||||
personName: 'Dhruv Garg',
|
||||
role: 'Tech Lead at Nudge',
|
||||
customerName: 'Nudge',
|
||||
@@ -74,7 +65,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-vivek-bhakta',
|
||||
avatar: vivekBhaktaUrl,
|
||||
avatar: 'https://signoz.io/img/users/vivek-bhakta.webp',
|
||||
personName: 'Vivek Bhakta',
|
||||
role: 'CTO at Wombo AI',
|
||||
customerName: 'Wombo AI',
|
||||
@@ -84,7 +75,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-pranay-narang',
|
||||
avatar: pranayNarangUrl,
|
||||
avatar: 'https://signoz.io/img/users/pranay-narang.webp',
|
||||
personName: 'Pranay Narang',
|
||||
role: 'Engineering at Azodha',
|
||||
customerName: 'Azodha',
|
||||
@@ -94,7 +85,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-Sheheryar-Sewani',
|
||||
avatar: sheheryarSewaniUrl,
|
||||
avatar: 'https://signoz.io/img/users/shey.webp',
|
||||
personName: 'Sheheryar Sewani',
|
||||
role: 'Seasoned Rails Dev & Founder',
|
||||
customerName: '',
|
||||
@@ -105,7 +96,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-daniel-schell',
|
||||
avatar: danielSchellUrl,
|
||||
avatar: 'https://signoz.io/img/users/daniel.webp',
|
||||
personName: 'Daniel Schell',
|
||||
role: 'Founder & CTO at Airlockdigital',
|
||||
customerName: 'Airlockdigital',
|
||||
@@ -115,7 +106,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'c-story-6',
|
||||
avatar: goFrendiGunawanUrl,
|
||||
avatar: 'https://signoz.io/img/users/go-frendi.webp',
|
||||
personName: 'Go Frendi Gunawan',
|
||||
role: 'Data Engineer at Ctlyst.id',
|
||||
customerName: 'Ctlyst.id',
|
||||
@@ -125,7 +116,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-anselm-eickhoff',
|
||||
avatar: anselmEickhoffUrl,
|
||||
avatar: 'https://signoz.io/img/users/anselm.jpg',
|
||||
personName: 'Anselm Eickhoff',
|
||||
role: 'Software Architect',
|
||||
customerName: '',
|
||||
|
||||
2
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.6
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/bytedance/sonic v1.14.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
@@ -111,7 +112,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.16",
|
||||
Version: "v0.0.15",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,19 +40,11 @@ func stripKeyAlias(name string) string {
|
||||
return keyAliasRe.ReplaceAllString(name, "")
|
||||
}
|
||||
|
||||
// unwrapVariant decodes a scan envelope: the chcol.Variant the driver scans a Dynamic column (a JSON
|
||||
// path such as body_v2.level) into, and the JSONValue a whole JSON column scans into. The attributes
|
||||
// bag decodes flat so its dotted keys merge with the legacy attribute maps and a key stored at both a
|
||||
// scalar and an object path stays two keys; every other JSON column decodes nested.
|
||||
func unwrapVariant(name string, val any) any {
|
||||
switch v := val.(type) {
|
||||
case chcol.Variant:
|
||||
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
|
||||
// Dynamic column — a JSON path such as body_v2.level — into.
|
||||
func unwrapVariant(val any) any {
|
||||
if v, ok := val.(chcol.Variant); ok {
|
||||
return v.Any()
|
||||
case telemetrystoretypes.JSONValue:
|
||||
if name == "attributes" {
|
||||
return v.Flatten()
|
||||
}
|
||||
return v.Nest()
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -62,11 +54,11 @@ func unwrapVariant(name string, val any) any {
|
||||
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
|
||||
// structure, so two rows it considers equal have to produce the same label.
|
||||
func labelValue(val any) string {
|
||||
val = unwrapVariant("", val)
|
||||
val = unwrapVariant(val)
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := val.(map[string]any); ok {
|
||||
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
|
||||
if raw, err := json.Marshal(v); err == nil {
|
||||
return string(raw)
|
||||
}
|
||||
@@ -486,7 +478,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
|
||||
// 2. deref each slot into the output row
|
||||
row := make([]any, len(scan))
|
||||
for i, cell := range scan {
|
||||
row[i] = unwrapVariant(stripKeyAlias(colNames[i]), derefValue(cell))
|
||||
row[i] = unwrapVariant(derefValue(cell))
|
||||
}
|
||||
data = append(data, row)
|
||||
}
|
||||
@@ -544,7 +536,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
name := stripKeyAlias(colNames[i])
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
val := unwrapVariant(name, reflect.ValueOf(cellPtr).Elem().Interface())
|
||||
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
|
||||
|
||||
// special-case: timestamp column
|
||||
if name == "timestamp" || name == "timestamp_datetime" {
|
||||
@@ -583,15 +575,12 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
attrStr, hasStr := data["attributes_string"]
|
||||
attrNum, hasNum := data["attributes_number"]
|
||||
attrBool, hasBool := data["attributes_bool"]
|
||||
// readAsRaw already decoded the JSON column into a flat bag of dotted keys via unwrapVariant.
|
||||
attrJSON, _ := data["attributes"].(map[string]any)
|
||||
attrJSON, _ := data["attributes"].(telemetrystoretypes.JSONValue)
|
||||
// todo(nitya): move to resource json
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
for k, v := range attrJSON {
|
||||
attributes[k] = v
|
||||
}
|
||||
attrJSON.FlattenInto("", attributes)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
|
||||
@@ -3,13 +3,16 @@ package querier
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
@@ -80,40 +83,101 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// jsonColumn builds a JSONValue from dotted leaf paths, as ClickHouse stores them.
|
||||
func jsonColumn(paths map[string]any) telemetrystoretypes.JSONValue {
|
||||
j := chcol.NewJSON()
|
||||
for path, value := range paths {
|
||||
j.SetValueAtPath(path, value)
|
||||
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
|
||||
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
|
||||
func TestConsume_JSONColumn(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
body := `{"level":"error","attrs":{"code":500}}`
|
||||
wantBody := telemetrystoretypes.JSONValue{
|
||||
"level": "error",
|
||||
"attrs": map[string]any{"code": float64(500)},
|
||||
}
|
||||
return telemetrystoretypes.NewJSONValue(*j)
|
||||
|
||||
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
|
||||
t.Run("scalar", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.ScalarData)
|
||||
require.Len(t, data.Data, 2)
|
||||
assert.Equal(t, wantBody, data.Data[0][0])
|
||||
assert.Equal(t, uint64(3), data.Data[0][1])
|
||||
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
|
||||
assert.Equal(t, uint64(1), data.Data[1][1])
|
||||
})
|
||||
|
||||
t.Run("time series", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{ts, body, uint64(3)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 1)
|
||||
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
|
||||
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
|
||||
})
|
||||
|
||||
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
|
||||
// series rather than being dropped, which would merge every group into one
|
||||
t.Run("time series grouped by the JSON column", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{
|
||||
{ts, `{"level":"error"}`, uint64(7)},
|
||||
{ts, `{"level":"warn"}`, uint64(2)},
|
||||
}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 2)
|
||||
|
||||
got := map[string]float64{}
|
||||
for _, series := range data.Aggregations[0].Series {
|
||||
require.Len(t, series.Labels, 1)
|
||||
require.Len(t, series.Values, 1)
|
||||
got[series.Labels[0].Value.(string)] = series.Values[0].Value
|
||||
}
|
||||
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
|
||||
})
|
||||
|
||||
t.Run("raw", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "timestamp", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
}, [][]any{{ts, body}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.RawData)
|
||||
require.Len(t, data.Rows, 1)
|
||||
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
|
||||
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
|
||||
})
|
||||
}
|
||||
|
||||
// A JSON path (e.g. body_v2.level) comes back as a Dynamic column scanned into a chcol.Variant
|
||||
// envelope; a whole JSON column scans into a JSONValue, which decodes flat for the attributes bag and
|
||||
// nested for every other column.
|
||||
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
|
||||
// into a chcol.Variant envelope rather than the value itself.
|
||||
func TestUnwrapVariant(t *testing.T) {
|
||||
jsonCol := jsonColumn(map[string]any{"level": "error", "attrs.code": int64(500)})
|
||||
nullCol := jsonColumn(map[string]any{"level": "error", "absent": chcol.Variant{}})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
column string
|
||||
input any
|
||||
want any
|
||||
}{
|
||||
{name: "VariantScalar", column: "", input: chcol.NewDynamicWithType("error", "String"), want: "error"},
|
||||
{name: "EmptyDynamic", column: "", input: chcol.Dynamic{}, want: nil},
|
||||
{name: "PlainValuePassthrough", column: "", input: uint64(3), want: uint64(3)},
|
||||
{name: "JSONColumnNested", column: "body_v2", input: jsonCol, want: map[string]any{"level": "error", "attrs": map[string]any{"code": float64(500)}}},
|
||||
{name: "AttributesColumnFlat", column: "attributes", input: jsonCol, want: map[string]any{"level": "error", "attrs.code": float64(500)}},
|
||||
{name: "AttributesColumnDropsNullPaths", column: "attributes", input: nullCol, want: map[string]any{"level": "error"}},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, unwrapVariant(testCase.column, testCase.input))
|
||||
})
|
||||
}
|
||||
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
|
||||
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
|
||||
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
|
||||
}
|
||||
|
||||
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
@@ -132,8 +196,8 @@ func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// readAsRaw decodes the JSON column into a flat bag of dotted keys before this runs; mergeSpanAttributeColumns
|
||||
// copies that bag and overlays the legacy string/number/bool maps, which win on collision.
|
||||
// Arrays stay native leaves: the collector stringifies top-level arrays and explodes nested ones
|
||||
// into indexed keys in the legacy maps; the JSON home keeps them whole and we do not mimic either.
|
||||
func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -141,9 +205,12 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "JSONOnly_FlatBagCopied_PreservesTypes",
|
||||
name: "JSONOnly_FlattensNestedPaths_PreservesTypes",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http.route": "/api/pay", "http.retry.count": float64(3), "cache.hit": true},
|
||||
"attributes": telemetrystoretypes.JSONValue{
|
||||
"http": map[string]any{"route": "/api/pay", "retry": map[string]any{"count": float64(3)}},
|
||||
"cache.hit": true,
|
||||
},
|
||||
},
|
||||
want: map[string]any{"http.route": "/api/pay", "http.retry.count": float64(3), "cache.hit": true},
|
||||
},
|
||||
@@ -152,17 +219,17 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
data: map[string]any{
|
||||
"attributes_string": map[string]string{"http.route": "/old", "only.map": "m"},
|
||||
"attributes_number": map[string]float64{"http.status": 500},
|
||||
"attributes": map[string]any{"http.route": "/new", "only.json": "j"},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/new"}, "only.json": "j"},
|
||||
},
|
||||
want: map[string]any{"http.route": "/old", "only.map": "m", "http.status": float64(500), "only.json": "j"},
|
||||
},
|
||||
{
|
||||
name: "MapOnly_EmptyJSONBag_KeepsMapValues",
|
||||
name: "MapOnly_EmptyJSONDoc_KeepsMapValues",
|
||||
data: map[string]any{
|
||||
"attributes_string": map[string]string{"http.route": "/map"},
|
||||
"attributes_number": map[string]float64{"http.status": 200},
|
||||
"attributes_bool": map[string]bool{"cache.hit": true},
|
||||
"attributes": map[string]any{},
|
||||
"attributes": telemetrystoretypes.JSONValue{},
|
||||
},
|
||||
want: map[string]any{"http.route": "/map", "http.status": float64(200), "cache.hit": true},
|
||||
},
|
||||
@@ -170,39 +237,60 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
name: "MapOnly_NilJSON_BehavesAsAbsent",
|
||||
data: map[string]any{
|
||||
"attributes_string": map[string]string{"http.route": "/map"},
|
||||
"attributes": map[string]any(nil),
|
||||
"attributes": telemetrystoretypes.JSONValue(nil),
|
||||
},
|
||||
want: map[string]any{"http.route": "/map"},
|
||||
},
|
||||
{
|
||||
name: "Arrays_StayLeafValues",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
|
||||
},
|
||||
want: map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
|
||||
},
|
||||
{
|
||||
name: "ScalarAndObjectKey_BothSurvive",
|
||||
name: "TopLevelArrayOfMaps_StaysNativeLeaf",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
|
||||
"attributes": telemetrystoretypes.JSONValue{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
|
||||
},
|
||||
want: map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
|
||||
want: map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
|
||||
},
|
||||
{
|
||||
name: "DualWritten_IndexKeysAndJSONArrayCoexist",
|
||||
name: "NestedArrayOfMaps_StaysNativeLeaf_NoIndexPaths",
|
||||
data: map[string]any{
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
|
||||
},
|
||||
want: map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
|
||||
},
|
||||
{
|
||||
name: "DualWritten_NestedArray_IndexKeysAndJSONArrayCoexist",
|
||||
data: map[string]any{
|
||||
"attributes_number": map[string]float64{"http.items.0.a": 1},
|
||||
"attributes": map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
|
||||
},
|
||||
want: map[string]any{"http.items.0.a": float64(1), "http.items": []any{map[string]any{"a": float64(1)}}},
|
||||
},
|
||||
{
|
||||
name: "ScalarValueStaysScalar",
|
||||
name: "JSONNull_KeptAsNil",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http": "plaintext"},
|
||||
"attributes": telemetrystoretypes.JSONValue{"k": nil},
|
||||
},
|
||||
want: map[string]any{"k": nil},
|
||||
},
|
||||
{
|
||||
name: "KeyIsLeafValue_NotFlattened",
|
||||
data: map[string]any{
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": "plaintext"},
|
||||
},
|
||||
want: map[string]any{"http": "plaintext"},
|
||||
},
|
||||
{
|
||||
name: "KeyIsParent_FlattensToDottedPath",
|
||||
data: map[string]any{
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/a"}},
|
||||
},
|
||||
want: map[string]any{"http.route": "/a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -1207,7 +1208,7 @@ func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, res
|
||||
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
|
||||
// is legal under these names and left alone.
|
||||
func stripEmptyBodyMessage(val any) {
|
||||
bodyMap, ok := val.(map[string]any)
|
||||
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,10 +97,8 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
options.MaxIdleConns = config.Connection.MaxIdleConns
|
||||
options.MaxOpenConns = config.Connection.MaxOpenConns
|
||||
options.DialTimeout = config.Connection.DialTimeout
|
||||
// Read JSON columns natively: the driver mis-decodes JSON(max_dynamic_paths=0) columns without
|
||||
// the flattened serialization, and reading them as a string collapses a key stored at both a
|
||||
// scalar and an object path.
|
||||
options.Settings["output_format_native_use_flattened_dynamic_and_json_serialization"] = 1
|
||||
// This is to avoid the driver decoding issues with JSON columns
|
||||
options.Settings["output_format_native_write_json_as_string"] = 1
|
||||
|
||||
chConn, err := clickhouse.Open(options)
|
||||
if err != nil {
|
||||
|
||||
23
pkg/telemetrystore/telemetrystoretest/conn.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package telemetrystoretest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
)
|
||||
|
||||
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
|
||||
// type they do in production.
|
||||
type conn struct {
|
||||
clickhouse.Conn
|
||||
}
|
||||
|
||||
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
|
||||
rows, err := c.Conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return telemetrystore.WrapRows(rows), nil
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
|
||||
|
||||
// ClickhouseDB returns the mock Clickhouse connection.
|
||||
func (p *Provider) ClickhouseDB() clickhouse.Conn {
|
||||
return p.clickhouseDB.(clickhouse.Conn)
|
||||
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
|
||||
}
|
||||
|
||||
// Cluster returns the cluster name.
|
||||
|
||||
@@ -178,8 +178,9 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
maps.Copy(resources, storable.ResourcesString)
|
||||
timeUnixNano := uint64(storable.StartTime.UnixNano())
|
||||
attributes := threadAttributes(storable)
|
||||
// Spans from before the JSON rollout have an empty document and format to nothing.
|
||||
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON.Nest())
|
||||
// Spans from before the JSON rollout have no document and are not decoded: the legacy maps
|
||||
// hold an object split into one key per field.
|
||||
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON)
|
||||
return &ThreadSpan{
|
||||
SpanID: storable.SpanID,
|
||||
TraceID: traceID,
|
||||
@@ -205,9 +206,10 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
// threadAttributes flattens the JSON column into dotted keys, as the querier does for list
|
||||
// responses, and falls back to the legacy maps for spans written before the JSON rollout.
|
||||
func threadAttributes(storable *StorableSpan) map[string]any {
|
||||
attributes := storable.AttributesJSON.Flatten()
|
||||
if len(attributes) == 0 {
|
||||
if len(storable.AttributesJSON) == 0 {
|
||||
return storable.Attributes()
|
||||
}
|
||||
attributes := make(map[string]any, len(storable.AttributesJSON))
|
||||
storable.AttributesJSON.FlattenInto("", attributes)
|
||||
return attributes
|
||||
}
|
||||
|
||||
@@ -3,23 +3,11 @@ package spantypes
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
)
|
||||
|
||||
// jsonAttrs builds a JSONValue from dotted leaf paths, as ClickHouse stores them.
|
||||
func jsonAttrs(paths map[string]any) telemetrystoretypes.JSONValue {
|
||||
j := chcol.NewJSON()
|
||||
for path, value := range paths {
|
||||
j.SetValueAtPath(path, value)
|
||||
}
|
||||
return telemetrystoretypes.NewJSONValue(*j)
|
||||
}
|
||||
|
||||
func TestNewThreadQuery_Cursor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -60,7 +48,7 @@ func TestThreadAttributes(t *testing.T) {
|
||||
{
|
||||
name: "JSONColumnPresent_LegacyMapsIgnored",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
|
||||
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
|
||||
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
|
||||
@@ -78,22 +66,6 @@ func TestThreadAttributes(t *testing.T) {
|
||||
"gen_ai.stream": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
// ClickHouse materializes a row's absent paths as null from the part's path schema.
|
||||
name: "JSONColumn_DropsNullPaths",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
|
||||
},
|
||||
{
|
||||
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
|
||||
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
@@ -101,15 +73,3 @@ func TestThreadAttributes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThreadSpan_FormatsJSONMessages(t *testing.T) {
|
||||
storable := StorableSpan{
|
||||
SpanID: "f1fa1bc863e94dd0",
|
||||
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.input.messages": `[{"role":"user","content":"hi"}]`}),
|
||||
}
|
||||
|
||||
span := newThreadSpan("trace", &storable)
|
||||
|
||||
require.Len(t, span.FormattedInput, 1)
|
||||
assert.Equal(t, aiobservabilitytypes.RoleUser, span.FormattedInput[0].Role)
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
resources := make(map[string]string)
|
||||
maps.Copy(resources, item.ResourcesString)
|
||||
|
||||
formatted := aiobservabilitytypes.FormatMessages(item.AttributesJSON.Nest())
|
||||
formatted := aiobservabilitytypes.FormatMessages(item.AttributesJSON)
|
||||
return &WaterfallSpan{
|
||||
Attributes: item.Attributes(),
|
||||
DBName: item.DBName,
|
||||
|
||||
@@ -1,80 +1,55 @@
|
||||
package telemetrystoretypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var (
|
||||
_ chcol.JSONDeserializer = (*JSONValue)(nil)
|
||||
_ json.Marshaler = JSONValue{}
|
||||
)
|
||||
var ErrCodeUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
|
||||
// JSONValue is the scan target for a ClickHouse native JSON column. It holds the column as the driver's
|
||||
// chcol.JSON, so a key stored at both a scalar and an object path (db.function and db.function.arg) stays
|
||||
// two paths that nesting would collapse into one. Read it with Flatten for the flat attribute bag or Nest
|
||||
// for a nested document; both drop the null paths ClickHouse materializes for a row from its data part's
|
||||
// shared path schema.
|
||||
type JSONValue struct {
|
||||
column chcol.JSON
|
||||
}
|
||||
// JSONValue is the scan target for a ClickHouse JSON column: the connection sets
|
||||
// output_format_native_write_json_as_string, so the column arrives as a raw document rather than
|
||||
// the chcol.JSON the driver reports as its scan type.
|
||||
type JSONValue map[string]any
|
||||
|
||||
// NewJSONValue wraps a chcol.JSON a caller holds outside the native scan path.
|
||||
func NewJSONValue(column chcol.JSON) JSONValue {
|
||||
return JSONValue{column: column}
|
||||
}
|
||||
// Scan decodes into a fresh map every time: a scan target is reused across rows, and unmarshalling
|
||||
// into the map already there would both keep its keys and hand every row the same map.
|
||||
func (v *JSONValue) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch value := src.(type) {
|
||||
case nil:
|
||||
*v = nil
|
||||
return nil
|
||||
case string:
|
||||
raw = []byte(value)
|
||||
case []byte:
|
||||
raw = value
|
||||
default:
|
||||
return errors.NewInternalf(ErrCodeUnmarshalJSONColumn, "cannot decode %T as a JSON column", src)
|
||||
}
|
||||
|
||||
// DeserializeClickHouseJSON lets the native driver scan a JSON column straight into the wrapper.
|
||||
func (v *JSONValue) DeserializeClickHouseJSON(column *chcol.JSON) error {
|
||||
v.column = *column
|
||||
decoded := JSONValue{}
|
||||
if err := sonic.Unmarshal(raw, &decoded); err != nil {
|
||||
return errors.WrapInternalf(err, ErrCodeUnmarshalJSONColumn, "failed to unmarshal JSON column")
|
||||
}
|
||||
*v = decoded
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flatten returns the column as a flat map of dotted path to value, the shape of an attribute bag.
|
||||
// A path whose value is null is skipped.
|
||||
func (v JSONValue) Flatten() map[string]any {
|
||||
flat := make(map[string]any)
|
||||
for path, value := range v.column.ValuesByPath() {
|
||||
if isNull(value) {
|
||||
continue
|
||||
// FlattenInto writes v into out under dotted keys, overwriting existing keys.
|
||||
func (v JSONValue) FlattenInto(prefix string, out map[string]any) {
|
||||
for k, value := range v {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := value.(type) {
|
||||
case map[string]any:
|
||||
JSONValue(child).FlattenInto(key, out)
|
||||
case JSONValue:
|
||||
child.FlattenInto(key, out)
|
||||
default:
|
||||
out[key] = value
|
||||
}
|
||||
flat[path] = value
|
||||
}
|
||||
return unwrap(flat)
|
||||
}
|
||||
|
||||
// Nest returns the column as a nested document, the shape the logs body is returned to the client in.
|
||||
// A key stored at both a scalar and an object path collapses here, which Flatten avoids:
|
||||
// https://github.com/SigNoz/events-pod/issues/8
|
||||
func (v JSONValue) Nest() map[string]any {
|
||||
return unwrap(v.column.NestedMap())
|
||||
}
|
||||
|
||||
// MarshalJSON renders the flat attribute bag, so a reader that stores the scan target without calling
|
||||
// Flatten or Nest serializes the column instead of the empty struct.
|
||||
func (v JSONValue) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.Flatten())
|
||||
}
|
||||
|
||||
func isNull(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
variant, ok := value.(chcol.Variant)
|
||||
return ok && variant.Nil()
|
||||
}
|
||||
|
||||
// unwrap resolves the driver's Dynamic and Variant wrappers to plain Go types through a JSON round-trip,
|
||||
// so callers can type-assert the values. A document that does not round-trip is returned unchanged.
|
||||
func unwrap(doc map[string]any) map[string]any {
|
||||
raw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return doc
|
||||
}
|
||||
out := make(map[string]any, len(doc))
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return doc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package telemetrystoretypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jsonValue(paths map[string]any) JSONValue {
|
||||
j := chcol.NewJSON()
|
||||
for path, value := range paths {
|
||||
j.SetValueAtPath(path, value)
|
||||
}
|
||||
return JSONValue{column: *j}
|
||||
}
|
||||
|
||||
func TestFlatten(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
value JSONValue
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "ScalarAndObjectKeyBothSurvive",
|
||||
value: jsonValue(map[string]any{"db.function": "refresh", "db.function.arg": int64(2)}),
|
||||
want: map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
|
||||
},
|
||||
{
|
||||
name: "DropsNullPaths",
|
||||
value: jsonValue(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
|
||||
want: map[string]any{"gen_ai.request.model": "gpt-4o"},
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
value: jsonValue(map[string]any{}),
|
||||
want: map[string]any{},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, testCase.value.Flatten())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNest(t *testing.T) {
|
||||
value := jsonValue(map[string]any{
|
||||
"gen_ai.request.model": "gpt-4o",
|
||||
"gen_ai.prompt.0.content": "hi",
|
||||
"gen_ai.prompt.0.role": "user",
|
||||
"gen_ai.output.messages": chcol.Variant{},
|
||||
})
|
||||
want := map[string]any{
|
||||
"gen_ai": map[string]any{
|
||||
"request": map[string]any{"model": "gpt-4o"},
|
||||
"prompt": map[string]any{"0": map[string]any{"content": "hi", "role": "user"}},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, want, value.Nest())
|
||||
}
|
||||
|
||||
func TestMarshalJSON(t *testing.T) {
|
||||
value := jsonValue(map[string]any{
|
||||
"gen_ai.request.model": "gpt-4o",
|
||||
"gen_ai.output.messages": chcol.Variant{},
|
||||
})
|
||||
|
||||
raw, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"gen_ai.request.model":"gpt-4o"}`, string(raw))
|
||||
}
|
||||
138
tests/e2e/drivers/dashboard-list.ts
generated
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
DASHBOARDS_API,
|
||||
removeTrackedDashboards,
|
||||
trackDashboard,
|
||||
} from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The dashboards list page (`/dashboard`): the New dashboard modal's Blank tab
|
||||
// and the per-row actions menu.
|
||||
|
||||
const LIST_PATH = '/dashboard';
|
||||
|
||||
/** A dashboard route, not the list or an editor route. */
|
||||
const DASHBOARD_ROUTE = /\/dashboard\/([^/?#]+)(?:[?#]|$)/;
|
||||
|
||||
export interface NewDashboardFields {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `key:value`, each committed with Enter. */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface DashboardList {
|
||||
/** Narrowed by the `query` param to one name, since the list is paged. */
|
||||
open(name?: string): Promise<void>;
|
||||
newDashboardButton(): Locator;
|
||||
nameInput(): Locator;
|
||||
descriptionInput(): Locator;
|
||||
tagsInput(): Locator;
|
||||
tagChips(): Locator;
|
||||
submit(): Locator;
|
||||
/**
|
||||
* Fills and submits the Blank tab, then returns the id from the route it
|
||||
* lands on. The dashboard is deleted when the test ends.
|
||||
*/
|
||||
create(fields: NewDashboardFields): Promise<string>;
|
||||
/** Rows render titles in `dashboard-title-<index>`, not by id. */
|
||||
rowTitle(name: string): Locator;
|
||||
/** Opens the row's actions menu and confirms Delete. */
|
||||
delete(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
function dashboardListDriver(page: Page): DashboardList {
|
||||
const rowTitle = (name: string): Locator =>
|
||||
page
|
||||
.locator('[data-testid^="dashboard-title-"]')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) });
|
||||
|
||||
const list: DashboardList = {
|
||||
async open(name) {
|
||||
const search = name
|
||||
? `?${new URLSearchParams({ query: `name CONTAINS '${name}'` }).toString()}`
|
||||
: '';
|
||||
await page.goto(`${LIST_PATH}${search}`);
|
||||
await expect(list.newDashboardButton()).toBeVisible();
|
||||
},
|
||||
newDashboardButton: () => page.getByTestId('new-dashboard-cta'),
|
||||
nameInput: () => page.getByTestId('create-dashboard-name'),
|
||||
descriptionInput: () => page.getByTestId('create-dashboard-description'),
|
||||
tagsInput: () => page.getByTestId('create-dashboard-tags'),
|
||||
tagChips: () => page.getByTestId('create-dashboard-tags-chip'),
|
||||
submit: () => page.getByTestId('create-dashboard-submit'),
|
||||
async create({ name, description, tags = [] }) {
|
||||
await list.newDashboardButton().click();
|
||||
await list.nameInput().fill(name);
|
||||
if (description !== undefined) {
|
||||
await list.descriptionInput().fill(description);
|
||||
}
|
||||
for (const tag of tags) {
|
||||
await list.tagsInput().fill(tag);
|
||||
await list.tagsInput().press('Enter');
|
||||
}
|
||||
await expect(list.tagChips()).toHaveCount(tags.length);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' &&
|
||||
new URL(r.url()).pathname === DASHBOARDS_API,
|
||||
),
|
||||
list.submit().click(),
|
||||
]);
|
||||
// Tracked from the response, before the route settles, so a failed
|
||||
// navigation still cleans up.
|
||||
expect(response.ok(), `POST ${DASHBOARDS_API} failed`).toBe(true);
|
||||
const created = ((await response.json()) as { data: { id: string } }).data
|
||||
.id;
|
||||
trackDashboard(page, created);
|
||||
|
||||
await page.waitForURL(DASHBOARD_ROUTE);
|
||||
const id = DASHBOARD_ROUTE.exec(new URL(page.url()).pathname)?.[1];
|
||||
expect(id).toBe(created);
|
||||
return created;
|
||||
},
|
||||
rowTitle,
|
||||
async delete(name) {
|
||||
const row = rowTitle(name).locator(
|
||||
'xpath=ancestor::div[.//*[@data-testid="dashboard-action-icon"]][1]',
|
||||
);
|
||||
await row.getByTestId('dashboard-action-icon').click();
|
||||
await page.getByTestId('dashboard-action-delete').click();
|
||||
// The confirm is an antd `modal.confirm` with no testid.
|
||||
const confirm = page.locator('.ant-modal-confirm');
|
||||
await expect(confirm).toContainText(name);
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'DELETE' &&
|
||||
r.url().includes(`${DASHBOARDS_API}/`),
|
||||
),
|
||||
confirm.getByRole('button', { name: 'Delete', exact: true }).click(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const withDashboardList = driver(
|
||||
'dashboardList',
|
||||
({ page }) => dashboardListDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
export const test = withDashboardList(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
359
tests/e2e/drivers/dashboards.ts
generated
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Route,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken } from '../helpers/common';
|
||||
import type {
|
||||
PanelKind,
|
||||
PostableDashboardV2,
|
||||
} from '../helpers/dashboard-v2-spec';
|
||||
import {
|
||||
mockQueryRange,
|
||||
mockQueryRangeError,
|
||||
mockQueryRangeSequence,
|
||||
type MockOptions,
|
||||
type QueryRangeBody,
|
||||
unmockQueryRange,
|
||||
} from '../helpers/query-range-mock';
|
||||
|
||||
import { driver, kit } from './kit';
|
||||
|
||||
// `POST /api/v2/dashboards` takes the full perses spec in one call; the v1
|
||||
// endpoints the older helpers use answer NewV1DeprecatedError.
|
||||
|
||||
export const DASHBOARDS_API = '/api/v2/dashboards';
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
export const PATH = {
|
||||
dashboard: (id: string): string => `/dashboard/${id}`,
|
||||
/** `panelId` is 'new' for creation. */
|
||||
editor: (id: string, panelId: string): string =>
|
||||
`/dashboard/${id}/panel/${panelId}`,
|
||||
/** Matches the dashboard route itself, with or without a query string. */
|
||||
onDashboard: (id: string): RegExp => new RegExp(`/dashboard/${id}(\\?|$)`),
|
||||
/** Matches the dashboard route once the View modal has written its params. */
|
||||
onDashboardWithParams: (id: string): RegExp =>
|
||||
new RegExp(`/dashboard/${id}\\?`),
|
||||
newPanel: /\/panel\/new\?/,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `panel-editor-v2` is not unique: the ResizablePanelGroup derives the same
|
||||
* testid from its `id`, which is also its localStorage layout key.
|
||||
*/
|
||||
export const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
|
||||
|
||||
export interface GettableDashboardV2 {
|
||||
id: string;
|
||||
name: string;
|
||||
locked: boolean;
|
||||
tags: { key: string; value: string }[];
|
||||
spec: PostableDashboardV2['spec'];
|
||||
}
|
||||
|
||||
export interface Dashboards {
|
||||
/** Created for this test and deleted when it ends. */
|
||||
seed(dashboard: PostableDashboardV2): Promise<string>;
|
||||
seedAndOpen(dashboard: PostableDashboardV2): Promise<string>;
|
||||
seedAndEdit(dashboard: PostableDashboardV2, panelId: string): Promise<string>;
|
||||
/** Deletes a dashboard the UI created when the test ends. */
|
||||
track(id: string): void;
|
||||
/** The open dashboard's heading. */
|
||||
title(): Locator;
|
||||
/** Waits for the grid, not just the shell. */
|
||||
open(id: string): Promise<void>;
|
||||
/** For a dashboard with no panels: waits for the empty state instead. */
|
||||
openEmpty(id: string): Promise<void>;
|
||||
openEditor(id: string, panelId: string): Promise<void>;
|
||||
/** `kind` is required: without it the route bounces to the dashboard. */
|
||||
openNewPanel(id: string, kind: PanelKind, layoutIndex?: number): Promise<void>;
|
||||
/** Navigates without waiting for anything, for routes expected to redirect. */
|
||||
visit(path: string): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
get(id: string): Promise<GettableDashboardV2>;
|
||||
setLocked(id: string, locked: boolean): Promise<void>;
|
||||
/** A panel's grid geometry as persisted, not as rendered. */
|
||||
gridItem(id: string, panelId: string): Promise<GridGeometry>;
|
||||
/** A query param of the current URL, where the time window lives. */
|
||||
param(name: string): string | null;
|
||||
emptyAddPanel(): Locator;
|
||||
headerAddPanel(): Locator;
|
||||
zoomOut(): Locator;
|
||||
variable(name: string): Locator;
|
||||
confirmDelete(): Locator;
|
||||
/** Free text anywhere on the page, e.g. a confirm dialog's title. */
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
export interface GridGeometry {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface QueryRangeMock {
|
||||
/** Fulfils every call with one body. Install before navigating. */
|
||||
respond(body: QueryRangeBody, options?: MockOptions): Promise<void>;
|
||||
fail(
|
||||
message?: string,
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void>;
|
||||
/** One body per call, repeating the last. */
|
||||
sequence(bodies: QueryRangeBody[]): Promise<void>;
|
||||
/** Holds every call until `release` is called, then serves `body`. */
|
||||
hold(body: QueryRangeBody): Promise<{ release: () => void }>;
|
||||
/** Drops interception so later calls reach the backend. */
|
||||
reset(): Promise<void>;
|
||||
/** Resolves with the next query_range request. */
|
||||
nextRequest(): Promise<Request>;
|
||||
/** The paging of the first query in the next query_range request. */
|
||||
nextPaging(): Promise<{ offset?: number; limit?: number }>;
|
||||
/** Counts query_range requests from now on. */
|
||||
countRequests(): { readonly count: number };
|
||||
}
|
||||
|
||||
async function bearer(page: Page): Promise<{ Authorization: string }> {
|
||||
return { Authorization: `Bearer ${await authToken(page)}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 400 is almost always the strict decoder rejecting a stray key; the body
|
||||
* names the field, so it is surfaced verbatim.
|
||||
*/
|
||||
async function createViaApi(
|
||||
page: Page,
|
||||
dashboard: PostableDashboardV2,
|
||||
): Promise<string> {
|
||||
const res = await page.request.post(DASHBOARDS_API, {
|
||||
data: dashboard,
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST ${DASHBOARDS_API} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboardV2 }).data.id;
|
||||
}
|
||||
|
||||
export async function getViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<GettableDashboardV2> {
|
||||
const res = await page.request.get(`${DASHBOARDS_API}/${id}`, {
|
||||
headers: await bearer(page),
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET ${DASHBOARDS_API}/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return ((await res.json()) as { data: GettableDashboardV2 }).data;
|
||||
}
|
||||
|
||||
/** Same path, no body: PUT locks, DELETE unlocks. */
|
||||
async function setLockedViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
locked: boolean,
|
||||
): Promise<void> {
|
||||
const url = `${DASHBOARDS_API}/${id}/lock`;
|
||||
const headers = await bearer(page);
|
||||
const res = locked
|
||||
? await page.request.put(url, { headers })
|
||||
: await page.request.delete(url, { headers });
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`${locked ? 'PUT' : 'DELETE'} ${url} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const trackedByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/** Registers a dashboard for deletion when the test owning `page` ends. */
|
||||
export function trackDashboard(page: Page, id: string): void {
|
||||
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not asserted: a test that deleted its own dashboard, or left a closed page
|
||||
* behind, must not fail on cleanup.
|
||||
*/
|
||||
export async function removeTrackedDashboards(page: Page): Promise<void> {
|
||||
const ids = trackedByPage.get(page) ?? [];
|
||||
trackedByPage.delete(page);
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const headers = await bearer(page);
|
||||
for (const id of ids) {
|
||||
await page.request
|
||||
.delete(`${DASHBOARDS_API}/${id}`, { headers })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
/* the dashboards outlive the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardsDriver(page: Page): Dashboards {
|
||||
const editorRoot = (): Locator => page.locator(EDITOR_ROOT);
|
||||
|
||||
const dashboards: Dashboards = {
|
||||
async seed(dashboard) {
|
||||
const id = await createViaApi(page, dashboard);
|
||||
trackDashboard(page, id);
|
||||
return id;
|
||||
},
|
||||
track: (id) => trackDashboard(page, id),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
async seedAndOpen(dashboard) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.open(id);
|
||||
return id;
|
||||
},
|
||||
async seedAndEdit(dashboard, panelId) {
|
||||
const id = await dashboards.seed(dashboard);
|
||||
await dashboards.openEditor(id, panelId);
|
||||
return id;
|
||||
},
|
||||
async open(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await page
|
||||
.locator('[data-panel-root]')
|
||||
.first()
|
||||
.waitFor({ state: 'attached' });
|
||||
},
|
||||
async openEmpty(id) {
|
||||
await page.goto(PATH.dashboard(id));
|
||||
await expect(dashboards.emptyAddPanel()).toBeVisible();
|
||||
},
|
||||
async openEditor(id, panelId) {
|
||||
await page.goto(PATH.editor(id, panelId));
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async openNewPanel(id, kind, layoutIndex = 0) {
|
||||
const search = new URLSearchParams({
|
||||
panelKind: kind,
|
||||
layoutIndex: String(layoutIndex),
|
||||
});
|
||||
await page.goto(`${PATH.editor(id, 'new')}?${search.toString()}`);
|
||||
await expect(editorRoot()).toBeVisible();
|
||||
},
|
||||
async visit(path) {
|
||||
await page.goto(path);
|
||||
},
|
||||
async reload() {
|
||||
await page.reload();
|
||||
},
|
||||
get: (id) => getViaApi(page, id),
|
||||
setLocked: (id, locked) => setLockedViaApi(page, id, locked),
|
||||
async gridItem(id, panelId) {
|
||||
const { spec } = await getViaApi(page, id);
|
||||
for (const layout of spec.layouts) {
|
||||
const item = layout.spec.items.find(
|
||||
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
|
||||
);
|
||||
if (item) {
|
||||
return { x: item.x, y: item.y, width: item.width, height: item.height };
|
||||
}
|
||||
}
|
||||
throw new Error(`no grid item for panel ${panelId}`);
|
||||
},
|
||||
param: (name) => new URL(page.url()).searchParams.get(name),
|
||||
emptyAddPanel: () => page.getByTestId('add-panel'),
|
||||
headerAddPanel: () => page.getByTestId('add-panel-header'),
|
||||
zoomOut: () => page.getByTestId('zoom-out-btn'),
|
||||
variable: (name) => page.getByTestId(`variable-${name}`),
|
||||
confirmDelete: () => page.getByTestId('confirm-delete'),
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return dashboards;
|
||||
}
|
||||
|
||||
function queryRangeDriver(page: Page): QueryRangeMock {
|
||||
const isQueryRange = (url: string): boolean => url.includes('/query_range');
|
||||
|
||||
return {
|
||||
respond: (body, options) => mockQueryRange(page, body, options),
|
||||
fail: (message, options) => mockQueryRangeError(page, message, options),
|
||||
sequence: (bodies) => mockQueryRangeSequence(page, bodies),
|
||||
async hold(body) {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
await gate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
return { release: () => release() };
|
||||
},
|
||||
reset: () => unmockQueryRange(page),
|
||||
nextRequest: () => page.waitForRequest((r) => isQueryRange(r.url())),
|
||||
async nextPaging() {
|
||||
const request = await page.waitForRequest((r) => isQueryRange(r.url()));
|
||||
const body = request.postDataJSON() as {
|
||||
compositeQuery?: {
|
||||
queries?: { spec?: { offset?: number; limit?: number } }[];
|
||||
};
|
||||
} | null;
|
||||
const spec = body?.compositeQuery?.queries?.[0]?.spec;
|
||||
return { offset: spec?.offset, limit: spec?.limit };
|
||||
},
|
||||
countRequests() {
|
||||
const counter = { count: 0 };
|
||||
page.on('request', (request) => {
|
||||
if (isQueryRange(request.url())) {
|
||||
counter.count += 1;
|
||||
}
|
||||
});
|
||||
return counter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const withDashboards = driver(
|
||||
'dashboards',
|
||||
({ page }) => dashboardsDriver(page),
|
||||
(_, { page }) => removeTrackedDashboards(page),
|
||||
);
|
||||
|
||||
const withQueryRange = driver('queryRange', ({ page }) =>
|
||||
queryRangeDriver(page),
|
||||
);
|
||||
|
||||
export const test = withQueryRange(withDashboards(kit));
|
||||
|
||||
export { expect } from './kit';
|
||||
export * from '../helpers/dashboard-v2-spec';
|
||||
export {
|
||||
flat,
|
||||
type HeatmapPoint,
|
||||
heatmapPoints,
|
||||
type HeatmapSeriesSpec,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
ramp,
|
||||
type SeriesSpec,
|
||||
timestamps,
|
||||
type TimeSeriesPoint,
|
||||
} from '../helpers/query-range-mock';
|
||||
export * from '../testdata/v2/panels-dashboard';
|
||||
13
tests/e2e/drivers/kit.ts
generated
@@ -27,10 +27,15 @@ export type WorkerLayer<Adds> = <T extends object, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T, W & Adds>;
|
||||
|
||||
/** A `withX` layer binding `build` to the authenticated page. */
|
||||
/**
|
||||
* A `withX` layer binding `build` to the authenticated page. `dispose` runs
|
||||
* after the test while the page is still open, so it can clean up over the
|
||||
* same session.
|
||||
*/
|
||||
export function driver<Name extends string, T>(
|
||||
name: Name,
|
||||
build: (ctx: DriverContext) => T,
|
||||
dispose?: (value: T, ctx: DriverContext) => Promise<void>,
|
||||
): Layer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
// Playwright reads fixture dependencies from the source text, so
|
||||
@@ -39,7 +44,11 @@ export function driver<Name extends string, T>(
|
||||
{ authedPage }: { authedPage: Page },
|
||||
use: (value: T) => Promise<void>,
|
||||
) => {
|
||||
await use(build({ page: authedPage }));
|
||||
const value = build({ page: authedPage });
|
||||
await use(value);
|
||||
if (dispose) {
|
||||
await dispose(value, { page: authedPage });
|
||||
}
|
||||
},
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
|
||||
278
tests/e2e/drivers/panel-config.ts
generated
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import type {
|
||||
ContextLink,
|
||||
PanelPluginSpec,
|
||||
Threshold,
|
||||
} from '../helpers/dashboard-v2-spec';
|
||||
import { SINGLE_PANEL_ID } from '../testdata/v2/panels-dashboard';
|
||||
|
||||
import { getViaApi } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { test as editorTest } from './panel-editor';
|
||||
|
||||
// The panel editor's per-kind config editors: thresholds, context links,
|
||||
// per-column units and the List columns editor.
|
||||
|
||||
/**
|
||||
* Variant follows panel kind, not a user control: label → TimeSeries/Bar,
|
||||
* comparison → Number, table → Table. Each has its own add button and row
|
||||
* testid prefix.
|
||||
*/
|
||||
export type ThresholdVariant = 'label' | 'comparison' | 'table';
|
||||
|
||||
const THRESHOLD_ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
label: 'panel-editor-v2-add-threshold',
|
||||
comparison: 'panel-editor-v2-add-comparison-threshold',
|
||||
table: 'panel-editor-v2-add-table-threshold',
|
||||
};
|
||||
|
||||
const THRESHOLD_ROW_PREFIX: Record<ThresholdVariant, string> = {
|
||||
label: 'threshold',
|
||||
comparison: 'comparison-threshold',
|
||||
table: 'table-threshold',
|
||||
};
|
||||
|
||||
export type ThresholdOperator =
|
||||
| 'above'
|
||||
| 'above_or_equal'
|
||||
| 'below'
|
||||
| 'below_or_equal'
|
||||
| 'equal'
|
||||
| 'not_equal';
|
||||
|
||||
export type ThresholdFormat = 'background' | 'text';
|
||||
|
||||
export interface ThresholdRow {
|
||||
value(): Locator;
|
||||
/** Label variant only. */
|
||||
label(): Locator;
|
||||
edit(): Locator;
|
||||
save(): Locator;
|
||||
discard(): Locator;
|
||||
remove(): Locator;
|
||||
/** Operator and format are radio tiles keyed by the persisted value. */
|
||||
setOperator(operator: ThresholdOperator): Promise<void>;
|
||||
setFormat(format: ThresholdFormat): Promise<void>;
|
||||
/** Table variant only: the column keys come from the live result. */
|
||||
pickFirstColumn(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Thresholds {
|
||||
addButton(variant: ThresholdVariant): Locator;
|
||||
/** The section header's quick-add, which also expands a collapsed section. */
|
||||
headerAdd(): Locator;
|
||||
row(variant: ThresholdVariant, index: number): ThresholdRow;
|
||||
}
|
||||
|
||||
export interface ContextLinks {
|
||||
addButton(): Locator;
|
||||
headerAdd(): Locator;
|
||||
dialog(): Locator;
|
||||
label(): Locator;
|
||||
url(): Locator;
|
||||
save(): Locator;
|
||||
cancel(): Locator;
|
||||
addParam(): Locator;
|
||||
paramKey(index: number): Locator;
|
||||
paramValue(index: number): Locator;
|
||||
item(index: number): Locator;
|
||||
edit(index: number): Locator;
|
||||
remove(index: number): Locator;
|
||||
/**
|
||||
* Closes the URL field's suggestion popover, which otherwise keeps the
|
||||
* dialog reflowing so buttons never settle. Escape would dismiss the whole
|
||||
* dialog.
|
||||
*/
|
||||
blurFields(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ListColumns {
|
||||
editor(): Locator;
|
||||
chip(name: string): Locator;
|
||||
remove(name: string): Locator;
|
||||
addButton(): Locator;
|
||||
search(): Locator;
|
||||
/** The explicit "Add …" entry, so a typo can't be committed by blurring. */
|
||||
addCustom(): Locator;
|
||||
/** Opens the dropdown and types `term`. */
|
||||
open(term: string): Promise<void>;
|
||||
/**
|
||||
* Clicks the first suggestion once the results for `term` have landed;
|
||||
* returns its field name.
|
||||
*/
|
||||
pickFirstSuggestion(term: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface TableCells {
|
||||
/** The last cell of the first row, where the aggregated value lands. */
|
||||
valueCell(): Locator;
|
||||
/** Inline background, set only while a background threshold matches. */
|
||||
background(cell: Locator): Promise<string>;
|
||||
/** Spans the text format recolours. */
|
||||
coloredText(cell: Locator): Locator;
|
||||
}
|
||||
|
||||
export interface SavedPanel {
|
||||
plugin: PanelPluginSpec;
|
||||
links: ContextLink[];
|
||||
}
|
||||
|
||||
export interface PanelConfig {
|
||||
thresholds: Thresholds;
|
||||
contextLinks: ContextLinks;
|
||||
listColumns: ListColumns;
|
||||
columnUnits: {
|
||||
any(): Locator;
|
||||
/** The first per-column unit select, with the column key it targets. */
|
||||
first(): Promise<{ testId: string; key: string }>;
|
||||
};
|
||||
/** The plugin spec and links as persisted, straight from the API. */
|
||||
saved(dashboardId: string, panelId?: string): Promise<SavedPanel>;
|
||||
savedThresholds(dashboardId: string, panelId?: string): Promise<Threshold[]>;
|
||||
numberValue(): Locator;
|
||||
/** Inline colour on the rendered Number value, set only while a threshold matches. */
|
||||
numberValueColor(): Promise<string>;
|
||||
table: TableCells;
|
||||
text(text: string): Locator;
|
||||
}
|
||||
|
||||
const COLUMN_UNIT_PREFIX = 'panel-editor-v2-column-unit-';
|
||||
const SUGGESTION_PREFIX = 'list-columns-suggestion-';
|
||||
|
||||
function thresholdRow(
|
||||
page: Page,
|
||||
variant: ThresholdVariant,
|
||||
index: number,
|
||||
): ThresholdRow {
|
||||
const prefix = THRESHOLD_ROW_PREFIX[variant];
|
||||
const byTestId = (part: string): Locator =>
|
||||
page.getByTestId(`${prefix}-${part}-${index}`);
|
||||
return {
|
||||
value: () => byTestId('value'),
|
||||
label: () => byTestId('label'),
|
||||
edit: () => byTestId('edit'),
|
||||
save: () => byTestId('save'),
|
||||
discard: () => byTestId('discard'),
|
||||
remove: () => byTestId('remove'),
|
||||
async setOperator(operator) {
|
||||
await page.getByTestId(`${prefix}-operator-${index}-${operator}`).click();
|
||||
},
|
||||
async setFormat(format) {
|
||||
await page.getByTestId(`${prefix}-format-${index}-${format}`).click();
|
||||
},
|
||||
async pickFirstColumn() {
|
||||
await byTestId('column').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.locator('.ant-select-item-option')
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function panelConfigDriver(page: Page): PanelConfig {
|
||||
const numberValue = (): Locator => page.getByTestId('number-panel-value');
|
||||
const contextLinkLabel = (): Locator => page.getByTestId('context-link-label');
|
||||
const columnUnits = (): Locator =>
|
||||
page.locator(`[data-testid^="${COLUMN_UNIT_PREFIX}"]`);
|
||||
|
||||
const config: PanelConfig = {
|
||||
thresholds: {
|
||||
addButton: (variant) => page.getByTestId(THRESHOLD_ADD_TESTID[variant]),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-threshold-header'),
|
||||
row: (variant, index) => thresholdRow(page, variant, index),
|
||||
},
|
||||
contextLinks: {
|
||||
addButton: () => page.getByTestId('panel-editor-v2-add-link'),
|
||||
headerAdd: () => page.getByTestId('panel-editor-v2-add-link-header'),
|
||||
dialog: () => page.getByTestId('context-link-dialog'),
|
||||
label: contextLinkLabel,
|
||||
url: () => page.getByTestId('context-link-url'),
|
||||
save: () => page.getByTestId('context-link-save'),
|
||||
cancel: () => page.getByTestId('context-link-cancel'),
|
||||
addParam: () => page.getByTestId('context-link-add-param'),
|
||||
paramKey: (index) => page.getByTestId(`context-link-param-key-${index}`),
|
||||
paramValue: (index) => page.getByTestId(`context-link-param-value-${index}`),
|
||||
item: (index) => page.getByTestId(`context-link-item-${index}`),
|
||||
edit: (index) => page.getByTestId(`context-link-edit-${index}`),
|
||||
remove: (index) => page.getByTestId(`context-link-remove-${index}`),
|
||||
async blurFields() {
|
||||
await contextLinkLabel().focus();
|
||||
},
|
||||
},
|
||||
listColumns: {
|
||||
editor: () => page.getByTestId('list-columns-editor'),
|
||||
chip: (name) => page.getByTestId(`list-column-chip-${name}`),
|
||||
remove: (name) => page.getByTestId(`list-column-remove-${name}`),
|
||||
addButton: () => page.getByTestId('list-columns-add'),
|
||||
search: () => page.getByTestId('list-columns-search'),
|
||||
addCustom: () => page.getByTestId('list-columns-add-custom'),
|
||||
async open(term) {
|
||||
await page.getByTestId('list-columns-add').click();
|
||||
await page.getByTestId('list-columns-search').fill(term);
|
||||
},
|
||||
async pickFirstSuggestion(term) {
|
||||
const first = page.locator(`[data-testid^="${SUGGESTION_PREFIX}"]`).first();
|
||||
const firstName = async (): Promise<string> =>
|
||||
((await first.getAttribute('data-testid')) ?? '').replace(
|
||||
SUGGESTION_PREFIX,
|
||||
'',
|
||||
);
|
||||
// Search is debounced, so the previous results stay up until this
|
||||
// term's land; and the list repaints as they do, so the row is
|
||||
// clicked by its own testid rather than by position.
|
||||
await expect(page.getByText('Loading…')).toHaveCount(0);
|
||||
await expect.poll(firstName).toContain(term);
|
||||
const name = await firstName();
|
||||
await page.getByTestId(`${SUGGESTION_PREFIX}${name}`).click();
|
||||
return name;
|
||||
},
|
||||
},
|
||||
columnUnits: {
|
||||
any: () => columnUnits().first(),
|
||||
async first() {
|
||||
const testId =
|
||||
(await columnUnits().first().getAttribute('data-testid')) ?? '';
|
||||
return { testId, key: testId.replace(COLUMN_UNIT_PREFIX, '') };
|
||||
},
|
||||
},
|
||||
async saved(dashboardId, panelId = SINGLE_PANEL_ID) {
|
||||
const { spec } = await getViaApi(page, dashboardId);
|
||||
const panel = spec.panels[panelId].spec;
|
||||
return { plugin: panel.plugin.spec, links: panel.links ?? [] };
|
||||
},
|
||||
async savedThresholds(dashboardId, panelId) {
|
||||
return (await config.saved(dashboardId, panelId)).plugin.thresholds ?? [];
|
||||
},
|
||||
numberValue,
|
||||
numberValueColor: () =>
|
||||
numberValue().evaluate((node) => (node as HTMLElement).style.color),
|
||||
table: {
|
||||
valueCell: () =>
|
||||
page
|
||||
.getByTestId('table-panel-renderer')
|
||||
.locator('tbody tr.ant-table-row')
|
||||
.first()
|
||||
.locator('td')
|
||||
.last(),
|
||||
background: (cell) =>
|
||||
cell.evaluate((node) => (node as HTMLElement).style.backgroundColor),
|
||||
coloredText: (cell) => cell.locator('span[style*="color"]'),
|
||||
},
|
||||
text: (text) => page.getByText(text),
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
const withPanelConfig = driver('config', ({ page }) => panelConfigDriver(page));
|
||||
|
||||
export const test = withPanelConfig(editorTest);
|
||||
|
||||
export * from './panel-editor';
|
||||
296
tests/e2e/drivers/panel-editor.ts
generated
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-v2-spec';
|
||||
|
||||
import { EDITOR_ROOT } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { newPanelDriver } from './panel-editor/picker';
|
||||
import { type QueryPane, queryPaneAt } from './panel-editor/query';
|
||||
import { type EditorSelects, selectsAt } from './panel-editor/select';
|
||||
import { type TextPane, textPaneAt } from './panel-editor/text';
|
||||
import {
|
||||
type TypeSwitcher,
|
||||
typeSwitcherAt,
|
||||
} from './panel-editor/type-switcher';
|
||||
import {
|
||||
type Chart,
|
||||
chartAt,
|
||||
type Markdown,
|
||||
markdownAt,
|
||||
RENDERER_TESTID,
|
||||
test as panelsTest,
|
||||
} from './panels';
|
||||
|
||||
// The V2 panel editor; config sections only mount their editors while open.
|
||||
// Each pane lives in its own part under ./panel-editor.
|
||||
|
||||
export const EditorText = {
|
||||
title: 'Configure panel',
|
||||
unsavedBadge: 'Unsaved Changes',
|
||||
save: 'Save changes',
|
||||
switchToView: 'Switch to View Mode',
|
||||
discardTitle: 'Discard changes?',
|
||||
discardBody: 'Your unsaved edits to this panel will be lost.',
|
||||
savedToast: 'Panel saved',
|
||||
lockedReason: 'This dashboard is locked',
|
||||
runQuery: 'Run Query',
|
||||
} as const;
|
||||
|
||||
export const QueryTab = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse Query',
|
||||
promql: 'PromQL',
|
||||
ai: 'AI Query Builder',
|
||||
} as const;
|
||||
|
||||
/** SettingsSection titles as rendered. */
|
||||
export const Section = {
|
||||
visualization: 'Visualization',
|
||||
formatting: 'Formatting & Units',
|
||||
axes: 'Axes',
|
||||
legend: 'Legend',
|
||||
chartAppearance: 'Chart Appearance',
|
||||
buckets: 'Buckets',
|
||||
thresholds: 'Thresholds',
|
||||
contextLinks: 'Context Links',
|
||||
/** Text only. */
|
||||
panelAppearance: 'Panel appearance',
|
||||
} as const;
|
||||
|
||||
/** Config controls, each testid'd as `panel-editor-v2-<name>`. */
|
||||
export type EditorControl =
|
||||
| 'soft-min'
|
||||
| 'soft-max'
|
||||
| 'y-scale'
|
||||
| 'log-scale'
|
||||
| 'unit'
|
||||
| 'decimals'
|
||||
| 'time-preference'
|
||||
| 'stacked-bar-chart'
|
||||
| 'stack-mode'
|
||||
| 'fill-spans'
|
||||
| 'fill-mode'
|
||||
| 'fill-opacity'
|
||||
| 'span-gaps'
|
||||
| 'span-gaps-value'
|
||||
| 'show-points'
|
||||
| 'line-style'
|
||||
| 'line-interpolation'
|
||||
| 'legend-position'
|
||||
| 'legend-colors'
|
||||
| 'legend-search'
|
||||
| 'bucket-count'
|
||||
| 'bucket-width'
|
||||
| 'merge-queries'
|
||||
| 'heatmap-color-mode'
|
||||
| 'heatmap-palette'
|
||||
| 'heatmap-color-scale'
|
||||
| 'heatmap-color-steps'
|
||||
| 'heatmap-min-count'
|
||||
| 'heatmap-max-count'
|
||||
| 'heatmap-fill'
|
||||
| 'heatmap-preview'
|
||||
| 'type-revert';
|
||||
|
||||
export interface PatchOperation {
|
||||
op: string;
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface PanelEditor extends EditorSelects, TypeSwitcher {
|
||||
root(): Locator;
|
||||
title(): Locator;
|
||||
description(): Locator;
|
||||
saveButton(): Locator;
|
||||
closeButton(): Locator;
|
||||
unsavedBadge(): Locator;
|
||||
switchToView(): Locator;
|
||||
/** Save is not gated on dirty state; fails the test on a rejected PATCH. */
|
||||
save(): Promise<void>;
|
||||
/** Clicks Save and returns whether the PATCH was accepted, for rejection paths. */
|
||||
trySave(): Promise<{ ok: boolean; body: string }>;
|
||||
/**
|
||||
* A dirty panel raises the discard dialog; `expectDirty` answers it, with
|
||||
* `keepEditing` choosing Cancel over Discard.
|
||||
*/
|
||||
close(options?: {
|
||||
expectDirty?: boolean;
|
||||
keepEditing?: boolean;
|
||||
}): Promise<void>;
|
||||
discardModal(): Locator;
|
||||
/** Records the RFC-6902 ops each save sends. */
|
||||
capturePatches(): PatchOperation[][];
|
||||
section: {
|
||||
toggle(title: string): Locator;
|
||||
/** Idempotent: a blind click on an open section collapses it. */
|
||||
expand(title: string): Promise<void>;
|
||||
collapse(title: string): Promise<void>;
|
||||
};
|
||||
control(name: EditorControl): Locator;
|
||||
/**
|
||||
* A ConfigTiles choice. The radio input is visually hidden, so the label
|
||||
* carrying `<group testid>-<value>` is the click target.
|
||||
*/
|
||||
tile(name: EditorControl, value: string): Locator;
|
||||
setTile(name: EditorControl, value: string): Promise<void>;
|
||||
/** The tile's radio input, for `toBeChecked`. */
|
||||
tileInput(name: EditorControl, value: string): Locator;
|
||||
/**
|
||||
* Whether a refresh or tab close would be held by the editor's
|
||||
* `beforeunload` prompt, checked without leaving the page.
|
||||
*/
|
||||
blocksUnload(): Promise<boolean>;
|
||||
/**
|
||||
* Drives a slider control from the keyboard ('Home', 'End', 'ArrowRight', …);
|
||||
* a key commits like a released drag.
|
||||
*/
|
||||
pressSlider(name: EditorControl, key: string): Promise<void>;
|
||||
text: TextPane;
|
||||
/** The heading text, which carries no testid. */
|
||||
heading(): Locator;
|
||||
/** A disabled Save swallows pointer events, so this hovers its tooltip trigger. */
|
||||
hoverSave(): Promise<void>;
|
||||
query: QueryPane;
|
||||
preview: {
|
||||
root(): Locator;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
chart: Chart;
|
||||
markdown: Markdown;
|
||||
};
|
||||
}
|
||||
|
||||
function isDashboardPatch(method: string, url: string): boolean {
|
||||
return method === 'PATCH' && /\/api\/v2\/dashboards\//.test(url);
|
||||
}
|
||||
|
||||
function editorDriver(page: Page): PanelEditor {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
page.getByTestId(`panel-editor-v2-${suffix}`);
|
||||
const preview = (): Locator => page.getByTestId('preview-pane');
|
||||
const sectionToggle = (title: string): Locator =>
|
||||
page.getByTestId(
|
||||
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
);
|
||||
|
||||
const editor: PanelEditor = {
|
||||
root: () => page.locator(EDITOR_ROOT),
|
||||
title: () => byTestId('title'),
|
||||
description: () => byTestId('description'),
|
||||
saveButton: () => byTestId('save'),
|
||||
closeButton: () => byTestId('close'),
|
||||
unsavedBadge: () => byTestId('unsaved-badge'),
|
||||
switchToView: () => byTestId('switch-to-view'),
|
||||
async save() {
|
||||
const { ok, body } = await editor.trySave();
|
||||
// A rejected patch leaves the editor open, which would otherwise surface
|
||||
// as an unrelated timeout several lines later.
|
||||
expect(ok, `dashboard PATCH failed: ${body}`).toBe(true);
|
||||
},
|
||||
async trySave() {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) =>
|
||||
isDashboardPatch(r.request().method(), r.url()),
|
||||
),
|
||||
byTestId('save').click(),
|
||||
]);
|
||||
return { ok: response.ok(), body: await response.text() };
|
||||
},
|
||||
async close(options) {
|
||||
await byTestId('close').click();
|
||||
if (!options?.expectDirty) {
|
||||
return;
|
||||
}
|
||||
await expect(byTestId('discard-modal')).toBeVisible();
|
||||
await byTestId(
|
||||
options.keepEditing ? 'discard-cancel' : 'discard-confirm',
|
||||
).click();
|
||||
},
|
||||
discardModal: () => byTestId('discard-modal'),
|
||||
capturePatches() {
|
||||
const batches: PatchOperation[][] = [];
|
||||
page.on('request', (request) => {
|
||||
if (!isDashboardPatch(request.method(), request.url())) {
|
||||
return;
|
||||
}
|
||||
const body = request.postDataJSON() as PatchOperation[] | null;
|
||||
if (body) {
|
||||
batches.push(body);
|
||||
}
|
||||
});
|
||||
return batches;
|
||||
},
|
||||
section: {
|
||||
toggle: sectionToggle,
|
||||
async expand(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
},
|
||||
async collapse(title) {
|
||||
const toggle = sectionToggle(title);
|
||||
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
},
|
||||
},
|
||||
control: (name) => byTestId(name),
|
||||
tile: (name, value) => byTestId(`${name}-${value}`),
|
||||
async setTile(name, value) {
|
||||
await editor.tile(name, value).click();
|
||||
},
|
||||
tileInput: (name, value) => editor.tile(name, value).locator('input'),
|
||||
blocksUnload: () =>
|
||||
page.evaluate(() => {
|
||||
const event = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}),
|
||||
async pressSlider(name, key) {
|
||||
const thumb = byTestId(name).getByRole('slider');
|
||||
await thumb.focus();
|
||||
await thumb.press(key);
|
||||
},
|
||||
text: textPaneAt(page),
|
||||
...selectsAt(page),
|
||||
heading: () => editor.root().getByText(EditorText.title),
|
||||
async hoverSave() {
|
||||
await page
|
||||
.locator('[data-slot="tooltip-trigger"]')
|
||||
.filter({ has: byTestId('save') })
|
||||
.hover();
|
||||
},
|
||||
...typeSwitcherAt(page),
|
||||
query: queryPaneAt(page, EditorText.runQuery),
|
||||
preview: {
|
||||
root: preview,
|
||||
renderer: (kind) => preview().getByTestId(RENDERER_TESTID[kind]),
|
||||
chart: chartAt(page, () => preview().getByTestId('uplot-main-div')),
|
||||
markdown: markdownAt(() =>
|
||||
preview().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
const withEditor = driver('editor', ({ page }) => editorDriver(page));
|
||||
const withNewPanel = driver('newPanel', ({ page }) => newPanelDriver(page));
|
||||
|
||||
export const test = withNewPanel(withEditor(panelsTest));
|
||||
|
||||
export * from './panels';
|
||||
export * from './panel-editor/picker';
|
||||
export * from './panel-editor/query';
|
||||
export * from './panel-editor/select';
|
||||
export * from './panel-editor/text';
|
||||
export * from './panel-editor/type-switcher';
|
||||
131
tests/e2e/drivers/panel-editor/picker.ts
generated
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-v2-spec';
|
||||
import { PATH } from '../dashboards';
|
||||
|
||||
const NEW_PANEL_DRAWER = 'panel-type-drawer';
|
||||
|
||||
/** The type browser's catalog, in render order. */
|
||||
export const PANEL_TYPE_TILES: {
|
||||
kind: PanelKind;
|
||||
label: string;
|
||||
category: string;
|
||||
}[] = [
|
||||
{
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
category: 'Trends over time',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
category: 'Compare & rank',
|
||||
},
|
||||
{
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
category: 'Distributions',
|
||||
},
|
||||
{ kind: PanelKind.Heatmap, label: 'Heatmap', category: 'Distributions' },
|
||||
{ kind: PanelKind.Number, label: 'Number', category: 'Single values' },
|
||||
{ kind: PanelKind.Table, label: 'Table', category: 'Raw records' },
|
||||
{ kind: PanelKind.List, label: 'List', category: 'Raw records' },
|
||||
{ kind: PanelKind.Text, label: 'Text', category: 'Documentation' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The New panel drawer every Add panel trigger opens. A tile click only selects
|
||||
* (TimeSeries is preselected); the confirm button navigates to the editor.
|
||||
*/
|
||||
export interface NewPanelPicker {
|
||||
root(): Locator;
|
||||
tile(kind: PanelKind): Locator;
|
||||
/** Visible tiles, in render order. */
|
||||
tiles(): Locator;
|
||||
/** Visible group headings, in render order. */
|
||||
groups(): Locator;
|
||||
search(): Locator;
|
||||
/** A category chip; its accessible name carries the count. */
|
||||
category(label: string): Locator;
|
||||
emptyState(): Locator;
|
||||
/** Reads "Add panel", or "Add to <section>" once there is a choice. */
|
||||
confirmButton(): Locator;
|
||||
/** Selects without confirming. */
|
||||
pick(kind: PanelKind): Promise<void>;
|
||||
confirm(): Promise<void>;
|
||||
/** Selects and confirms, landing on the editor route. */
|
||||
choose(kind: PanelKind): Promise<void>;
|
||||
/** `value` is the layoutIndex, or 'root' for a root yet to be created. */
|
||||
chooseSection(value: string): Promise<void>;
|
||||
/** Switches the target to a new section named `title`. */
|
||||
newSection(title: string): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
/** An empty section's own New Panel CTA. Should move to drivers/dashboards.ts. */
|
||||
sectionAddPanel(): Locator;
|
||||
}
|
||||
|
||||
export function newPanelDriver(page: Page): NewPanelPicker {
|
||||
const root = (): Locator => page.getByTestId(NEW_PANEL_DRAWER);
|
||||
const tiles = (): Locator =>
|
||||
root().locator('[data-testid^="panel-type-signoz/"]');
|
||||
|
||||
const picker: NewPanelPicker = {
|
||||
root,
|
||||
tile: (kind) => root().getByTestId(`panel-type-${kind}`),
|
||||
tiles,
|
||||
groups: () => root().locator('section > span:first-child'),
|
||||
search: () => root().getByTestId('panel-type-search'),
|
||||
category: (label) =>
|
||||
root()
|
||||
.locator('button[aria-pressed]:not([data-testid])')
|
||||
.filter({ hasText: new RegExp(`^${escapeRegExp(label)}\\d+$`) }),
|
||||
emptyState: () => root().getByText(/No panel types match/),
|
||||
confirmButton: () => root().getByTestId('panel-type-confirm'),
|
||||
async pick(kind) {
|
||||
await picker.tile(kind).click();
|
||||
await expect(picker.tile(kind)).toHaveAttribute('aria-pressed', 'true');
|
||||
},
|
||||
async confirm() {
|
||||
await picker.confirmButton().click();
|
||||
await page.waitForURL(PATH.newPanel);
|
||||
},
|
||||
async choose(kind) {
|
||||
await picker.pick(kind);
|
||||
await picker.confirm();
|
||||
},
|
||||
async chooseSection(value) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId(`panel-section-option-${value}`).click();
|
||||
},
|
||||
async newSection(title) {
|
||||
await root().getByRole('button', { name: 'Choose section' }).click();
|
||||
await page.getByTestId('panel-section-create').click();
|
||||
await root().getByTestId('panel-section-name').fill(title);
|
||||
},
|
||||
async cancel() {
|
||||
await root().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(root()).toHaveCount(0);
|
||||
},
|
||||
sectionAddPanel: () =>
|
||||
page.locator('[data-testid^="section-add-panel-"]').first(),
|
||||
};
|
||||
return picker;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
63
tests/e2e/drivers/panel-editor/query.ts
generated
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface QueryPane {
|
||||
builder(): Locator;
|
||||
tab(label: string): Locator;
|
||||
/** Has no testid at its call site. */
|
||||
runButton(): Locator;
|
||||
/** Clicks Run and waits for the response. */
|
||||
run(): Promise<void>;
|
||||
/** Ctrl/Cmd+Enter from inside the builder; waits for the response. */
|
||||
runShortcut(): Promise<void>;
|
||||
/** Picks a metric in the aggregation at `index`; options load as you type. */
|
||||
selectMetric(metricName: string, index?: number): Promise<void>;
|
||||
metricInput(index?: number): Locator;
|
||||
}
|
||||
|
||||
export function queryPaneAt(page: Page, runLabel: string): QueryPane {
|
||||
const builder = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-query-builder');
|
||||
const pane: QueryPane = {
|
||||
builder: () => builder(),
|
||||
// exact: 'Query Builder' is also a prefix of the 'AI Query Builder' tab.
|
||||
tab: (label) => builder().getByRole('tab', { name: label, exact: true }),
|
||||
runButton: () => page.getByRole('button', { name: runLabel }),
|
||||
async run() {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/query_range')),
|
||||
pane.runButton().click(),
|
||||
]);
|
||||
},
|
||||
async runShortcut() {
|
||||
await Promise.all([
|
||||
page.waitForResponse((r) => r.url().includes('/query_range')),
|
||||
(async () => {
|
||||
await builder().click();
|
||||
await page.keyboard.press('ControlOrMeta+Enter');
|
||||
})(),
|
||||
]);
|
||||
},
|
||||
async selectMetric(metricName, index = 0) {
|
||||
// An antd AutoComplete: the testid is on a wrapper.
|
||||
const field = page.getByTestId(`metric-name-selector-${index}`);
|
||||
await field.click();
|
||||
await field.locator('input').fill(metricName);
|
||||
const dropdown = page.locator(
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
||||
);
|
||||
await expect(dropdown).toBeVisible();
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
metricInput: (index = 0) =>
|
||||
page.getByTestId(`metric-name-selector-${index}`).locator('input'),
|
||||
};
|
||||
return pane;
|
||||
}
|
||||
76
tests/e2e/drivers/panel-editor/select.ts
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// antd popups portal to `document.body`, so a testid finds the trigger and the
|
||||
// options live in a detached `.ant-select-dropdown`.
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface EditorSelects {
|
||||
/** Picks an antd Select option by the trigger's testid. */
|
||||
select(triggerTestId: string, optionLabel: string): Promise<void>;
|
||||
/** Types first: long option lists are virtualised. */
|
||||
searchSelect(
|
||||
triggerTestId: string,
|
||||
searchTerm: string,
|
||||
optionLabel: string,
|
||||
): Promise<void>;
|
||||
selectOptions(triggerTestId: string): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
async function openDropdown(
|
||||
page: Page,
|
||||
triggerTestId: string,
|
||||
): Promise<Locator> {
|
||||
const trigger = page.getByTestId(triggerTestId);
|
||||
await trigger.click();
|
||||
// Resolve this Select's own list: a closing dropdown still matches
|
||||
// `:not(.ant-select-dropdown-hidden)`, so two in a row trip strict mode.
|
||||
const listId = await trigger.locator('input').getAttribute('aria-controls');
|
||||
const dropdown = listId
|
||||
? page
|
||||
.locator(`#${listId}`)
|
||||
.locator('xpath=ancestor::div[contains(@class,"ant-select-dropdown")][1]')
|
||||
: page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.last();
|
||||
await expect(dropdown).toBeVisible();
|
||||
return dropdown;
|
||||
}
|
||||
|
||||
export function selectsAt(page: Page): EditorSelects {
|
||||
return {
|
||||
async select(triggerTestId, optionLabel) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
async searchSelect(triggerTestId, searchTerm, optionLabel) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
await page.getByTestId(triggerTestId).locator('input').fill(searchTerm);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: optionLabel })
|
||||
.first()
|
||||
.click();
|
||||
},
|
||||
async selectOptions(triggerTestId) {
|
||||
const dropdown = await openDropdown(page, triggerTestId);
|
||||
return dropdown.locator('.ant-select-item-option').evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label: node.textContent?.trim() ?? '',
|
||||
disabled: node.classList.contains('ant-select-item-option-disabled'),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
50
tests/e2e/drivers/panel-editor/text.ts
generated
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/** The Text kind's editor pane and its own config controls. */
|
||||
export interface TextPane {
|
||||
pane(): Locator;
|
||||
/** The CodeMirror document. */
|
||||
input(): Locator;
|
||||
/**
|
||||
* Replaces the whole body. Inserted as text, not typed, so CodeMirror's
|
||||
* bracket auto-close cannot double `[` or `(`.
|
||||
*/
|
||||
setMarkdown(markdown: string): Promise<void>;
|
||||
toolbar(): Locator;
|
||||
/** Rendered only when the dashboard has variables. */
|
||||
insertVariableButton(): Locator;
|
||||
insertVariable(name: string): Promise<void>;
|
||||
/** The "Hide panel header" switch, inline under the title, not in a section. */
|
||||
hideHeader(): Locator;
|
||||
/** `vertical-horizontal`, e.g. 'center-center'. */
|
||||
alignment(vertical: string, horizontal: string): Locator;
|
||||
}
|
||||
|
||||
export function textPaneAt(page: Page): TextPane {
|
||||
const textPane = (): Locator => page.getByTestId('text-panel-editor-pane');
|
||||
const textInput = (): Locator => textPane().locator('.cm-content');
|
||||
return {
|
||||
pane: textPane,
|
||||
input: textInput,
|
||||
async setMarkdown(markdown) {
|
||||
await textInput().click();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await page.keyboard.press('Delete');
|
||||
await page.keyboard.insertText(markdown);
|
||||
},
|
||||
toolbar: () => textPane().getByTestId('markdown-editor-toolbar'),
|
||||
insertVariableButton: () =>
|
||||
textPane().getByTestId('markdown-insert-variable'),
|
||||
async insertVariable(name) {
|
||||
await textPane().getByTestId('markdown-insert-variable').click();
|
||||
await page.getByTestId(`markdown-variable-${name}`).click();
|
||||
},
|
||||
hideHeader: () => page.getByTestId('panel-header-hide'),
|
||||
alignment: (vertical, horizontal) =>
|
||||
page.getByTestId(`text-layout-align-${vertical}-${horizontal}`),
|
||||
};
|
||||
}
|
||||
69
tests/e2e/drivers/panel-editor/type-switcher.ts
generated
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../../helpers/dashboard-v2-spec';
|
||||
|
||||
import { type SelectOption } from './select';
|
||||
|
||||
const TYPE_SWITCHER_DRAWER = 'panel-type-switcher-drawer';
|
||||
|
||||
export interface TypeSwitcher {
|
||||
typeSwitcher(): Locator;
|
||||
/** Opens the kind picker drawer and picks one. */
|
||||
switchType(kind: PanelKind): Promise<void>;
|
||||
/** A tile in the open type-switcher drawer. */
|
||||
typeOption(kind: PanelKind): Locator;
|
||||
openTypeSwitcher(): Promise<Locator>;
|
||||
/** Every tile in the type switcher, opening it; `disabled` reads aria-disabled. */
|
||||
typeOptions(): Promise<SelectOption[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles are aria-disabled, not disabled, so their reason tooltip still hovers.
|
||||
* The label is the display name alone: the tile also renders a preview and a
|
||||
* description.
|
||||
*/
|
||||
async function tileOptions(scope: Locator): Promise<SelectOption[]> {
|
||||
const tiles = scope.locator('[data-testid^="panel-type-signoz/"]');
|
||||
await expect(tiles.first()).toBeVisible();
|
||||
return tiles.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
label:
|
||||
node
|
||||
.querySelector(
|
||||
':scope > span:last-child > span:first-child > span:first-child',
|
||||
)
|
||||
?.textContent?.trim() ?? '',
|
||||
disabled: node.getAttribute('aria-disabled') === 'true',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function typeSwitcherAt(page: Page): TypeSwitcher {
|
||||
const trigger = (): Locator =>
|
||||
page.getByTestId('panel-editor-v2-type-switcher');
|
||||
const switcher: TypeSwitcher = {
|
||||
typeSwitcher: trigger,
|
||||
typeOption: (kind) =>
|
||||
page.getByTestId(TYPE_SWITCHER_DRAWER).getByTestId(`panel-type-${kind}`),
|
||||
async openTypeSwitcher() {
|
||||
await trigger().click();
|
||||
const drawer = page.getByTestId(TYPE_SWITCHER_DRAWER);
|
||||
await expect(drawer).toBeVisible();
|
||||
return drawer;
|
||||
},
|
||||
async switchType(kind) {
|
||||
await switcher.openTypeSwitcher();
|
||||
await switcher.typeOption(kind).click();
|
||||
await expect(page.getByTestId(TYPE_SWITCHER_DRAWER)).toHaveCount(0);
|
||||
},
|
||||
async typeOptions() {
|
||||
const drawer = await switcher.openTypeSwitcher();
|
||||
return tileOptions(drawer);
|
||||
},
|
||||
};
|
||||
return switcher;
|
||||
}
|
||||
568
tests/e2e/drivers/panel-kinds.ts
generated
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
import {
|
||||
dashboardV2,
|
||||
type EditorControl,
|
||||
GOLDEN,
|
||||
heatmapPoints,
|
||||
heatmapTooltipAt,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
NEW_KINDS_MARKDOWN,
|
||||
type Panel,
|
||||
panel,
|
||||
type PanelHandle,
|
||||
PanelKind,
|
||||
type PostableDashboardV2,
|
||||
QueryRange,
|
||||
type QueryRangeBody,
|
||||
type QueryRangeMock,
|
||||
QueryTab,
|
||||
ramp,
|
||||
rawQuery,
|
||||
RENDERER_TESTID,
|
||||
Section,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
type ThresholdVariant,
|
||||
} from './panel-config';
|
||||
|
||||
// One row per panel kind, every value read off the kind's definition.ts and
|
||||
// sections.ts (and capabilities.ts for the query matrix). The contract specs
|
||||
// generate one test per row, so a new kind is one row here.
|
||||
|
||||
export type QueryType = 'builder' | 'clickhouse' | 'promql';
|
||||
|
||||
export type DownloadFormat = 'CSV' | 'PNG' | 'SVG';
|
||||
|
||||
/**
|
||||
* Every collapsible section title. Text's PanelHeader section is left out: it
|
||||
* renders inline in Panel Details, with no toggle of its own.
|
||||
*/
|
||||
export const ALL_SECTIONS: string[] = Object.values(Section);
|
||||
|
||||
/** The axis controls a kind can declare; Heatmap has `y-scale` only. */
|
||||
export const AXIS_CONTROLS: EditorControl[] = [
|
||||
'soft-min',
|
||||
'soft-max',
|
||||
'log-scale',
|
||||
'y-scale',
|
||||
];
|
||||
|
||||
export interface PanelKindCase {
|
||||
kind: PanelKind;
|
||||
/** `displayName`: the picker tile, switcher option and tooltip wording. */
|
||||
label: string;
|
||||
renderer: string;
|
||||
/** Renders from its own spec: no query, so no data states. */
|
||||
queryless: boolean;
|
||||
/** The panel as seeded; renders against golden data. */
|
||||
panel(): Panel;
|
||||
/** A one-panel dashboard holding `panel()` under SINGLE_PANEL_ID. */
|
||||
seed(): PostableDashboardV2;
|
||||
/** A mocked result the kind renders; null when queryless. */
|
||||
body(): QueryRangeBody | null;
|
||||
/** A mocked successful result with nothing in it; null when queryless. */
|
||||
emptyBody(): QueryRangeBody | null;
|
||||
/** Number renders the shared empty state under its own testid. */
|
||||
noDataTestId: string;
|
||||
sections: string[];
|
||||
forbiddenSections: string[];
|
||||
/** Visualization declares the time-preference control. */
|
||||
timePreference: boolean;
|
||||
/** Axis controls shown once Axes opens; the rest of AXIS_CONTROLS are absent. */
|
||||
axisControls: EditorControl[];
|
||||
queryTabs: string[];
|
||||
/** Query types the switcher accepts, AI excluded. */
|
||||
queryTypes: QueryType[];
|
||||
/** Builder signals the switcher accepts. */
|
||||
signals: Signal[];
|
||||
downloads: DownloadFormat[];
|
||||
createAlert: boolean;
|
||||
drilldown: boolean;
|
||||
/** The kind's data gesture: what a drilldown would hang off. */
|
||||
click(target: PanelHandle): Promise<void>;
|
||||
headerSearch: boolean;
|
||||
thresholdVariant: ThresholdVariant | null;
|
||||
columnsEditor: boolean;
|
||||
}
|
||||
|
||||
const BUCKETS = [10, 50, 100, 500];
|
||||
|
||||
const ALL_SIGNALS: Signal[] = ['metrics', 'logs', 'traces'];
|
||||
const ALL_TABS = [
|
||||
QueryTab.builder,
|
||||
QueryTab.clickhouse,
|
||||
QueryTab.promql,
|
||||
QueryTab.ai,
|
||||
];
|
||||
const IMAGES: DownloadFormat[] = ['PNG', 'SVG'];
|
||||
|
||||
const CHART_SECTIONS = [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
];
|
||||
|
||||
function oneSeries(): QueryRangeBody {
|
||||
return QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]);
|
||||
}
|
||||
|
||||
function serviceScalar(rows: (string | number)[][]): QueryRangeBody {
|
||||
return QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
function singlePanel(content: Panel): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
sections: [{ title: 'Section', panels: { [SINGLE_PANEL_ID]: content } }],
|
||||
});
|
||||
}
|
||||
|
||||
type RowInput = Omit<
|
||||
PanelKindCase,
|
||||
'renderer' | 'seed' | 'forbiddenSections' | 'noDataTestId'
|
||||
> &
|
||||
Partial<Pick<PanelKindCase, 'noDataTestId'>>;
|
||||
|
||||
function row(input: RowInput): PanelKindCase {
|
||||
return {
|
||||
noDataTestId: 'panel-no-data',
|
||||
...input,
|
||||
renderer: RENDERER_TESTID[input.kind],
|
||||
seed: () => singlePanel(input.panel()),
|
||||
forbiddenSections: ALL_SECTIONS.filter(
|
||||
(title) => !input.sections.includes(title),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const clickChartPoint = (target: PanelHandle): Promise<void> =>
|
||||
target.chart.clickPoint();
|
||||
const clickChart = (target: PanelHandle): Promise<void> => target.chart.click();
|
||||
|
||||
/**
|
||||
* A slice's bounding-box centre can fall in the donut hole, so this clicks the
|
||||
* centroid, where the slice's label leader line starts.
|
||||
*/
|
||||
async function clickPieSlice(target: PanelHandle): Promise<void> {
|
||||
const leader = target
|
||||
.renderer(PanelKind.PieChart)
|
||||
.locator('svg g line')
|
||||
.first();
|
||||
const { x, y } = await leader.evaluate((node) => {
|
||||
const line = node as SVGLineElement;
|
||||
const matrix = line.getScreenCTM();
|
||||
const point = new DOMPoint(
|
||||
line.x1.baseVal.value,
|
||||
line.y1.baseVal.value,
|
||||
).matrixTransform(matrix ?? undefined);
|
||||
return { x: point.x, y: point.y };
|
||||
});
|
||||
await target.root().page().mouse.click(x, y);
|
||||
}
|
||||
|
||||
/** Waits for the cell tooltip, so a missing menu is not a click that missed. */
|
||||
async function clickHeatmapCell(target: PanelHandle): Promise<void> {
|
||||
await target.chart.click();
|
||||
await expect(heatmapTooltipAt(target.root().page()).root()).toBeVisible();
|
||||
}
|
||||
|
||||
export const PANEL_KINDS: PanelKindCase[] = [
|
||||
row({
|
||||
kind: PanelKind.TimeSeries,
|
||||
label: 'Time Series',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.TimeSeries, { name: 'Calls by service' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.BarChart,
|
||||
label: 'Bar Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.BarChart, { name: 'Calls bar' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: CHART_SECTIONS,
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.AreaChart,
|
||||
label: 'Area Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.AreaChart, { name: 'Calls area' }),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [...CHART_SECTIONS, Section.chartAppearance],
|
||||
timePreference: true,
|
||||
axisControls: ['soft-min', 'soft-max', 'log-scale'],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
click: clickChartPoint,
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'label',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Histogram,
|
||||
label: 'Histogram',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({ metricName: GOLDEN.metrics.latencySum, groupBy: [] }),
|
||||
}),
|
||||
body: oneSeries,
|
||||
emptyBody: () => QueryRange.empty(),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.legend,
|
||||
Section.buckets,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: false,
|
||||
click: clickChart,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Number,
|
||||
label: 'Number',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
body: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [[1234]] }),
|
||||
emptyBody: () => QueryRange.scalar({ aggregationColumns: ['A'], rows: [] }),
|
||||
noDataTestId: 'number-panel-no-data',
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: ALL_TABS,
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: true,
|
||||
drilldown: true,
|
||||
// The whole renderer is the click target.
|
||||
click: (target) => target.renderer(PanelKind.Number).click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: 'comparison',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.PieChart,
|
||||
label: 'Pie Chart',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.PieChart, { name: 'Calls share' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.legend,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: clickPieSlice,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Table,
|
||||
label: 'Table',
|
||||
queryless: false,
|
||||
panel: () => panel(PanelKind.Table, { name: 'Calls table' }),
|
||||
body: () =>
|
||||
serviceScalar([
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
]),
|
||||
emptyBody: () => serviceScalar([]),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.formatting,
|
||||
Section.thresholds,
|
||||
Section.contextLinks,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.ai],
|
||||
queryTypes: ['builder', 'clickhouse'],
|
||||
signals: ALL_SIGNALS,
|
||||
downloads: ['CSV', ...IMAGES],
|
||||
createAlert: false,
|
||||
drilldown: true,
|
||||
click: (target) => target.table.rows().first().locator('td').last().click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: 'table',
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.List,
|
||||
label: 'List',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
body: () =>
|
||||
QueryRange.raw(
|
||||
Array.from({ length: 3 }, (_, i) => ({
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
body: `log line ${i}`,
|
||||
'service.name': 'adservice',
|
||||
})),
|
||||
),
|
||||
emptyBody: () => QueryRange.raw([]),
|
||||
sections: [Section.visualization],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [QueryTab.builder, QueryTab.ai],
|
||||
queryTypes: ['builder'],
|
||||
signals: ['logs', 'traces'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) =>
|
||||
target.renderer(PanelKind.List).getByText('log line 0').click(),
|
||||
headerSearch: true,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: true,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Heatmap,
|
||||
label: 'Heatmap',
|
||||
queryless: false,
|
||||
panel: () =>
|
||||
panel(PanelKind.Heatmap, { name: 'Calls heatmap', query: heatmapQuery() }),
|
||||
body: () =>
|
||||
QueryRange.heatmap(BUCKETS, [
|
||||
{
|
||||
labels: { 'service.name': 'adservice' },
|
||||
points: heatmapPoints(12, BUCKETS.length + 1),
|
||||
},
|
||||
]),
|
||||
// Bucket bounds but no series: the heatmap's own empty body.
|
||||
emptyBody: () => QueryRange.heatmap(BUCKETS, []),
|
||||
sections: [
|
||||
Section.visualization,
|
||||
Section.chartAppearance,
|
||||
Section.formatting,
|
||||
Section.axes,
|
||||
Section.legend,
|
||||
],
|
||||
timePreference: true,
|
||||
axisControls: ['y-scale'],
|
||||
queryTabs: [QueryTab.builder, QueryTab.clickhouse, QueryTab.promql],
|
||||
queryTypes: ['builder', 'clickhouse', 'promql'],
|
||||
signals: ['metrics'],
|
||||
downloads: IMAGES,
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: clickHeatmapCell,
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
row({
|
||||
kind: PanelKind.Text,
|
||||
label: 'Text',
|
||||
queryless: true,
|
||||
panel: () =>
|
||||
panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
body: () => null,
|
||||
emptyBody: () => null,
|
||||
sections: [Section.visualization, Section.panelAppearance],
|
||||
timePreference: false,
|
||||
axisControls: [],
|
||||
queryTabs: [],
|
||||
queryTypes: [],
|
||||
signals: [],
|
||||
downloads: [],
|
||||
createAlert: false,
|
||||
drilldown: false,
|
||||
click: (target) => target.markdown.root().click(),
|
||||
headerSearch: false,
|
||||
thresholdVariant: null,
|
||||
columnsEditor: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export interface NoDataState {
|
||||
root(): Locator;
|
||||
action(): Locator;
|
||||
secondaryAction(): Locator;
|
||||
}
|
||||
|
||||
/** The kind's empty state; its actions take the state's testid as a prefix. */
|
||||
export function noDataOf(c: PanelKindCase, target: PanelHandle): NoDataState {
|
||||
const byTestId = (suffix: string): Locator =>
|
||||
target.root().getByTestId(`${c.noDataTestId}${suffix}`);
|
||||
return {
|
||||
root: () => byTestId(''),
|
||||
action: () => byTestId('-action'),
|
||||
secondaryAction: () => byTestId('-secondary-action'),
|
||||
};
|
||||
}
|
||||
|
||||
export const QUERYABLE_KINDS = PANEL_KINDS.filter((c) => !c.queryless);
|
||||
|
||||
export function kindCase(kind: PanelKind): PanelKindCase {
|
||||
const match = PANEL_KINDS.find((c) => c.kind === kind);
|
||||
if (!match) {
|
||||
throw new Error(`no PANEL_KINDS row for ${kind}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kind's panel under SINGLE_PANEL_ID, below a tall queryless filler so it
|
||||
* starts outside the viewport.
|
||||
*/
|
||||
export function belowTheFold(c: PanelKindCase): PostableDashboardV2 {
|
||||
const dashboard = dashboardV2({
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
filler: panel(PanelKind.Text, { name: 'Filler' }),
|
||||
[SINGLE_PANEL_ID]: c.panel(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const [layout] = dashboard.spec.layouts;
|
||||
layout.spec.items = [
|
||||
{ ...layout.spec.items[0], x: 0, y: 0, width: 12, height: 40 },
|
||||
{ ...layout.spec.items[1], x: 0, y: 40 },
|
||||
];
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
/** Serves the kind's mocked result; a queryless kind sends no query to serve. */
|
||||
export async function respondWithData(
|
||||
queryRange: QueryRangeMock,
|
||||
c: PanelKindCase,
|
||||
): Promise<void> {
|
||||
const body = c.body();
|
||||
if (body) {
|
||||
await queryRange.respond(body);
|
||||
}
|
||||
}
|
||||
|
||||
/** The kind's panel plus an empty second section, so Move to section has a target. */
|
||||
export function withSpareSection(c: PanelKindCase): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
sections: [
|
||||
{ title: 'Section', panels: { [SINGLE_PANEL_ID]: c.panel() } },
|
||||
{ title: 'Spare', panels: {} },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher's reason for refusing `c`, or null when it accepts: the query
|
||||
* type is checked before the signal (getPanelTypeDisabledReason).
|
||||
*/
|
||||
export function disabledReason(
|
||||
c: PanelKindCase,
|
||||
source: { queryType: QueryType; signal?: Signal },
|
||||
): string | null {
|
||||
if (c.queryless) {
|
||||
return null;
|
||||
}
|
||||
if (!c.queryTypes.includes(source.queryType)) {
|
||||
const label = {
|
||||
builder: 'Query Builder',
|
||||
clickhouse: 'ClickHouse',
|
||||
promql: 'PromQL',
|
||||
}[source.queryType];
|
||||
return `${c.label} isn't available for ${label} queries`;
|
||||
}
|
||||
if (source.signal && !c.signals.includes(source.signal)) {
|
||||
return `${c.label} doesn't support ${source.signal} data`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export * from './panel-config';
|
||||
274
tests/e2e/drivers/panels.ts
generated
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { PanelKind } from '../helpers/dashboard-v2-spec';
|
||||
|
||||
import { test as dashboardsTest } from './dashboards';
|
||||
import { driver } from './kit';
|
||||
import { actionsAt, PanelAction, type PanelActions } from './panels/actions';
|
||||
import { type Chart, chartAt } from './panels/chart';
|
||||
import { openSelectOption } from './panels/dom';
|
||||
import { gridAt, type PanelGrid } from './panels/grid';
|
||||
import {
|
||||
type HeatmapPanel,
|
||||
type HeatmapTooltip,
|
||||
heatmapAt,
|
||||
heatmapTooltipAt,
|
||||
} from './panels/heatmap';
|
||||
import { type Legend, legendAt } from './panels/legend';
|
||||
import { type Markdown, markdownAt } from './panels/markdown';
|
||||
import { type PiePanel, pieAt } from './panels/pie';
|
||||
import {
|
||||
type ListPager,
|
||||
pagerAt,
|
||||
type PanelSearch,
|
||||
searchAt,
|
||||
type TableColumns,
|
||||
tableAt,
|
||||
} from './panels/table';
|
||||
|
||||
// Rendered V2 panels, on the dashboard grid and in the View modal. Each surface
|
||||
// lives in its own part under ./panels, and so does a kind with UI of its own.
|
||||
|
||||
export const PanelMessageText = {
|
||||
noQueryTitle: 'Nothing to visualize yet',
|
||||
// Curly apostrophe (U+2019): a straight quote will not match.
|
||||
errorTitle: 'Couldn’t load panel data',
|
||||
noDataTitle: 'No data in this time range',
|
||||
extendAction: 'Extend time range',
|
||||
retryAction: 'Retry',
|
||||
} as const;
|
||||
|
||||
/** Root testid each kind's renderer puts on its own subtree. */
|
||||
export const RENDERER_TESTID: Record<PanelKind, string> = {
|
||||
[PanelKind.TimeSeries]: 'time-series-renderer',
|
||||
[PanelKind.BarChart]: 'bar-panel-renderer',
|
||||
[PanelKind.AreaChart]: 'area-chart-renderer',
|
||||
[PanelKind.Histogram]: 'histogram-panel-renderer',
|
||||
[PanelKind.Number]: 'number-panel-renderer',
|
||||
[PanelKind.PieChart]: 'pie-panel-renderer',
|
||||
[PanelKind.Table]: 'table-panel-renderer',
|
||||
[PanelKind.List]: 'list-panel-renderer',
|
||||
[PanelKind.Heatmap]: 'heatmap-panel-renderer',
|
||||
[PanelKind.Text]: 'text-panel',
|
||||
};
|
||||
|
||||
export interface PanelHandle {
|
||||
root(): Locator;
|
||||
/** Panels fetch lazily behind an IntersectionObserver. */
|
||||
scrollIntoView(): Promise<void>;
|
||||
/** Scrolls in, then waits for the kind's own renderer. */
|
||||
waitRendered(kind: PanelKind): Promise<void>;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
/** `data-panel-visible`, 'true' once the panel entered the viewport. */
|
||||
visibility(): Locator;
|
||||
title(): Locator;
|
||||
infoIcon(): Locator;
|
||||
timePreference(): Locator;
|
||||
statusWarning(): Locator;
|
||||
statusError(): Locator;
|
||||
loading(): Locator;
|
||||
noData(): Locator;
|
||||
noDataAction(): Locator;
|
||||
noDataSecondaryAction(): Locator;
|
||||
error(): Locator;
|
||||
errorAction(): Locator;
|
||||
text(text: string): Locator;
|
||||
numberValue(): Locator;
|
||||
numberNoData(): Locator;
|
||||
/** Replaces the header when a Text panel hides it; carries the drag class. */
|
||||
hiddenHeaderHandle(): Locator;
|
||||
markdown: Markdown;
|
||||
heatmap: HeatmapPanel;
|
||||
pie: PiePanel;
|
||||
chart: Chart;
|
||||
legend: Legend;
|
||||
actions: PanelActions;
|
||||
search: PanelSearch;
|
||||
pager: ListPager;
|
||||
table: TableColumns;
|
||||
grid: PanelGrid;
|
||||
}
|
||||
|
||||
export interface Panels {
|
||||
panel(id: string): PanelHandle;
|
||||
/** Every panel title on the grid. */
|
||||
titles(): Locator;
|
||||
tooltip: {
|
||||
container(): Locator;
|
||||
list(): Locator;
|
||||
unpin(): Locator;
|
||||
/** The pin key is 'p' (DEFAULT_PIN_TOOLTIP_KEY); its JSDoc still says 'l'. */
|
||||
pin(): Promise<void>;
|
||||
/** Hover tooltips render their content twice, so this scopes to the tooltip role. */
|
||||
statusContent(): Locator;
|
||||
/** Free text inside any open tooltip. */
|
||||
text(text: string): Locator;
|
||||
heatmap: HeatmapTooltip;
|
||||
};
|
||||
drilldown: {
|
||||
menu(): Locator;
|
||||
/** Testids sit on an inner span; this walks up so disabled states assert. */
|
||||
item(testId: string): Locator;
|
||||
back(menu: 'breakout' | 'var'): Locator;
|
||||
breakoutSearch(): Locator;
|
||||
/** A page-level Escape does nothing: the handler needs the backdrop. */
|
||||
dismiss(): Promise<void>;
|
||||
};
|
||||
view: {
|
||||
/** Opens the modal through the actions menu. */
|
||||
open(panelId: string): Promise<Locator>;
|
||||
content(): Locator;
|
||||
dialog(): Locator;
|
||||
refresh(): Locator;
|
||||
switchToEdit(): Locator;
|
||||
chart: Chart;
|
||||
renderer(kind: PanelKind): Locator;
|
||||
markdown: Markdown;
|
||||
/** Text only: the Markdown editor in the query builder's slot. */
|
||||
textEditor(): Locator;
|
||||
/** Changes the kind for this session only. */
|
||||
switchType(label: string): Promise<void>;
|
||||
};
|
||||
/** A visible option of the open antd Select. */
|
||||
openSelectOption(label: string): Locator;
|
||||
/** Fires the browser's Back. */
|
||||
back(): Promise<void>;
|
||||
pressEscape(): Promise<void>;
|
||||
/** Resolves with the tab a click opens. */
|
||||
nextTab(): Promise<Page>;
|
||||
/** Resolves with the suggested filename of the next download. */
|
||||
nextDownload(): Promise<{ suggestedFilename: () => string }>;
|
||||
}
|
||||
|
||||
function panelHandle(page: Page, id: string): PanelHandle {
|
||||
const root = (): Locator => page.locator(`[data-panel-root="${id}"]`);
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
root,
|
||||
async scrollIntoView() {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
},
|
||||
async waitRendered(kind) {
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
await expect(root()).toHaveAttribute('data-panel-visible', 'true');
|
||||
await expect(byTestId(RENDERER_TESTID[kind])).toBeVisible();
|
||||
},
|
||||
renderer: (kind) => byTestId(RENDERER_TESTID[kind]),
|
||||
visibility: root,
|
||||
title: () => byTestId('panel-title'),
|
||||
infoIcon: () => byTestId('panel-header-info-icon'),
|
||||
timePreference: () => byTestId('panel-time-preference'),
|
||||
statusWarning: () => byTestId('panel-status-warning'),
|
||||
statusError: () => byTestId('panel-status-error'),
|
||||
loading: () => byTestId('panel-loading'),
|
||||
noData: () => byTestId('panel-no-data'),
|
||||
noDataAction: () => byTestId('panel-no-data-action'),
|
||||
noDataSecondaryAction: () => byTestId('panel-no-data-secondary-action'),
|
||||
error: () => byTestId('panel-error'),
|
||||
errorAction: () => byTestId('panel-error-action'),
|
||||
text: (text) => root().getByText(text),
|
||||
numberValue: () => byTestId('number-panel-value'),
|
||||
numberNoData: () => byTestId('number-panel-no-data'),
|
||||
hiddenHeaderHandle: () => byTestId('hidden-header-drag-handle'),
|
||||
markdown: markdownAt(() => byTestId(RENDERER_TESTID[PanelKind.Text])),
|
||||
heatmap: heatmapAt(root),
|
||||
pie: pieAt(root),
|
||||
chart: chartAt(page, () => byTestId('uplot-main-div')),
|
||||
legend: legendAt(root),
|
||||
actions: actionsAt(page, id, root),
|
||||
search: searchAt(root),
|
||||
pager: pagerAt(page, root),
|
||||
table: tableAt(page, id, root),
|
||||
grid: gridAt(page, root),
|
||||
};
|
||||
}
|
||||
|
||||
function panelsDriver(page: Page): Panels {
|
||||
const menu = (): Locator => page.locator('.context-menu');
|
||||
const viewContent = (): Locator =>
|
||||
page.getByTestId('view-panel-modal-content');
|
||||
|
||||
return {
|
||||
panel: (id) => panelHandle(page, id),
|
||||
titles: () => page.getByTestId('panel-title'),
|
||||
tooltip: {
|
||||
container: () => page.getByTestId('uplot-tooltip-container'),
|
||||
list: () => page.getByTestId('uplot-tooltip-list'),
|
||||
unpin: () => page.getByTestId('uplot-tooltip-unpin'),
|
||||
async pin() {
|
||||
await page.keyboard.press('p');
|
||||
},
|
||||
statusContent: () =>
|
||||
page.getByRole('tooltip').getByTestId('panel-status-content'),
|
||||
text: (text) => page.getByText(text),
|
||||
heatmap: heatmapTooltipAt(page),
|
||||
},
|
||||
drilldown: {
|
||||
menu,
|
||||
item: (testId) =>
|
||||
page
|
||||
.getByTestId(testId)
|
||||
.locator(
|
||||
'xpath=ancestor-or-self::button[contains(@class,"context-menu-item")][1]',
|
||||
),
|
||||
back: (submenu) => page.getByTestId(`drilldown-${submenu}-back`),
|
||||
breakoutSearch: () => page.getByPlaceholder('Search breakout options...'),
|
||||
async dismiss() {
|
||||
// A corner, since the menu opens over the clicked point.
|
||||
await page
|
||||
.locator('.context-menu-backdrop')
|
||||
.click({ position: { x: 5, y: 5 } });
|
||||
},
|
||||
},
|
||||
view: {
|
||||
async open(panelId) {
|
||||
await panelHandle(page, panelId).actions.run(PanelAction.view);
|
||||
await expect(viewContent()).toBeVisible();
|
||||
return viewContent();
|
||||
},
|
||||
content: viewContent,
|
||||
dialog: () => page.getByRole('dialog'),
|
||||
refresh: () => page.getByTestId('view-panel-refresh'),
|
||||
switchToEdit: () => page.getByTestId('view-panel-switch-to-edit'),
|
||||
chart: chartAt(page, () => viewContent().getByTestId('uplot-main-div')),
|
||||
renderer: (kind) => viewContent().getByTestId(RENDERER_TESTID[kind]),
|
||||
markdown: markdownAt(() =>
|
||||
viewContent().getByTestId(RENDERER_TESTID[PanelKind.Text]),
|
||||
),
|
||||
textEditor: () => viewContent().getByTestId('text-panel-editor-pane'),
|
||||
async switchType(label) {
|
||||
await page.getByTestId('view-panel-type-selector').click();
|
||||
await openSelectOption(page, label).click();
|
||||
},
|
||||
},
|
||||
openSelectOption: (label) => openSelectOption(page, label),
|
||||
async back() {
|
||||
await page.goBack();
|
||||
},
|
||||
async pressEscape() {
|
||||
await page.keyboard.press('Escape');
|
||||
},
|
||||
nextTab: () => page.context().waitForEvent('page'),
|
||||
nextDownload: () => page.waitForEvent('download'),
|
||||
};
|
||||
}
|
||||
|
||||
const withPanels = driver('panels', ({ page }) => panelsDriver(page));
|
||||
|
||||
export const test = withPanels(dashboardsTest);
|
||||
|
||||
export * from './dashboards';
|
||||
export * from './panels/actions';
|
||||
export * from './panels/chart';
|
||||
export * from './panels/dom';
|
||||
export * from './panels/grid';
|
||||
export * from './panels/heatmap';
|
||||
export * from './panels/legend';
|
||||
export * from './panels/markdown';
|
||||
export * from './panels/pie';
|
||||
export * from './panels/table';
|
||||
88
tests/e2e/drivers/panels/actions.ts
generated
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// Panel action menu items take no testid (@signozhq/ui's dropdown preset drops
|
||||
// it), so their visible label is the contract, matched by Radix's menuitem role.
|
||||
|
||||
export const PanelAction = {
|
||||
view: 'View',
|
||||
edit: 'Edit panel',
|
||||
clone: 'Clone',
|
||||
download: 'Download',
|
||||
downloadCsv: 'Download as CSV',
|
||||
downloadPng: 'Download as PNG',
|
||||
downloadSvg: 'Download as SVG',
|
||||
createAlert: 'Create Alerts',
|
||||
move: 'Move to section',
|
||||
moveToRoot: 'Dashboard (root)',
|
||||
delete: 'Delete panel',
|
||||
} as const;
|
||||
|
||||
export interface PanelActions {
|
||||
trigger(): Locator;
|
||||
/** Dismisses any menu still open first: one caught mid-dismiss eats the click. */
|
||||
open(): Promise<Locator>;
|
||||
close(): Promise<void>;
|
||||
item(label: string): Locator;
|
||||
run(label: string): Promise<void>;
|
||||
/** Hovers a parent item to open its submenu; clicking it closes the menu. */
|
||||
hoverItem(label: string): Promise<void>;
|
||||
download(format: 'CSV' | 'PNG' | 'SVG'): Promise<void>;
|
||||
moveTo(sectionTitle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function actionsAt(
|
||||
page: Page,
|
||||
panelId: string,
|
||||
root: () => Locator,
|
||||
): PanelActions {
|
||||
const menuItem = (label: string): Locator =>
|
||||
page.getByRole('menuitem', { name: label, exact: true });
|
||||
|
||||
const actions: PanelActions = {
|
||||
trigger: () => page.getByTestId(`panel-actions-${panelId}`),
|
||||
async open() {
|
||||
await actions.close();
|
||||
await root().scrollIntoViewIfNeeded();
|
||||
// The trigger only appears on hover.
|
||||
await root().hover();
|
||||
await actions.trigger().click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
},
|
||||
async close() {
|
||||
if ((await page.getByRole('menu').count()) === 0) {
|
||||
return;
|
||||
}
|
||||
// An open submenu takes the first Escape for itself.
|
||||
await expect(async () => {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menu')).toHaveCount(0, { timeout: 1_000 });
|
||||
}).toPass();
|
||||
},
|
||||
item: menuItem,
|
||||
async run(label) {
|
||||
await actions.open();
|
||||
await menuItem(label).click();
|
||||
},
|
||||
async hoverItem(label) {
|
||||
await menuItem(label).hover();
|
||||
},
|
||||
async download(format) {
|
||||
await actions.open();
|
||||
await menuItem(PanelAction.download).hover();
|
||||
await menuItem(`Download as ${format}`).click();
|
||||
},
|
||||
async moveTo(sectionTitle) {
|
||||
await actions.open();
|
||||
await menuItem(PanelAction.move).hover();
|
||||
await menuItem(sectionTitle).click();
|
||||
},
|
||||
};
|
||||
|
||||
return actions;
|
||||
}
|
||||
287
tests/e2e/drivers/panels/chart.ts
generated
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragHorizontally } from './dom';
|
||||
|
||||
/** uPlot encodes a log scale as `distr: 3`. */
|
||||
export const LOG_DISTR = 3;
|
||||
|
||||
export interface UPlotSeriesState {
|
||||
label?: string;
|
||||
/** false once a series is hidden via the legend. */
|
||||
show: boolean;
|
||||
/** Non-empty for a dashed line style. */
|
||||
dash?: number[];
|
||||
/** uPlot only sets a fill when the panel asks for one. */
|
||||
hasFill: boolean;
|
||||
/** The resolved fill: a colour (`#rrggbbaa` when solid) or 'gradient'. */
|
||||
fill: string | null;
|
||||
/** Largest finite value plotted, after any stacking; null when none. */
|
||||
max: number | null;
|
||||
/** A predicate cannot cross `evaluate`, so it reports as 'fn'. */
|
||||
pointsShow?: boolean | 'fn' | null;
|
||||
width?: number;
|
||||
/** uPlot's own gap bridging; false when a threshold breaks gaps in data-prep. */
|
||||
spanGaps?: boolean | number;
|
||||
/** Nulls in the plotted data: each one is a break in the line. */
|
||||
nullCount: number;
|
||||
}
|
||||
|
||||
export interface UPlotScaleState {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
/** 1 linear, 3 logarithmic. */
|
||||
distr?: number;
|
||||
}
|
||||
|
||||
export interface UPlotState {
|
||||
/** Excludes the x series at index 0. */
|
||||
series: UPlotSeriesState[];
|
||||
scales: Record<string, UPlotScaleState>;
|
||||
/** Points in the x series. */
|
||||
pointCount: number;
|
||||
/** Fill bands between series; a stacked chart has one per adjacent pair. */
|
||||
bandCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real chart state, read from the `__uplot` handle UPlotChart hangs off its
|
||||
* container. The DOM only says a chart exists; this catches a renderer that
|
||||
* ignores a setting.
|
||||
*/
|
||||
export interface Chart {
|
||||
host(): Locator;
|
||||
/** uPlot binds its cursor to `.u-over`, so this is the pointer target. */
|
||||
plotArea(): Locator;
|
||||
/** Throws when there is no plot, usually because the panel shows No Data. */
|
||||
state(): Promise<UPlotState>;
|
||||
/** The left scale, where axis bounds and log mode land. */
|
||||
yScale(): Promise<UPlotScaleState>;
|
||||
expectSeriesCount(count: number): Promise<void>;
|
||||
/**
|
||||
* Share (0–1) of the plot-area row at `value` on the left scale painted in
|
||||
* `color`. For lines drawn straight onto the canvas, such as thresholds,
|
||||
* which leave no state on the uPlot instance.
|
||||
*/
|
||||
lineCoverage(value: number, color: string): Promise<number>;
|
||||
/** Moves the pointer to the middle of the plot. */
|
||||
hover(): Promise<void>;
|
||||
/** Moves the pointer just outside the plot's top-left corner. */
|
||||
leave(): Promise<void>;
|
||||
click(): Promise<void>;
|
||||
/**
|
||||
* Clicks the first series' data point at `index`, by default the middle of
|
||||
* those inside the visible window, placed through uPlot's own scales so it
|
||||
* lands on data wherever it sits.
|
||||
*/
|
||||
clickPoint(index?: number): Promise<void>;
|
||||
/** Fractions are of the plot area's own box, so panel size does not matter. */
|
||||
drag(fromFraction: number, toFraction: number): Promise<void>;
|
||||
}
|
||||
|
||||
async function readUplot(host: Locator): Promise<UPlotState> {
|
||||
await expect(host).toBeVisible();
|
||||
return host.evaluate((node) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
series: {
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
dash?: number[];
|
||||
fill?: unknown;
|
||||
width?: number;
|
||||
spanGaps?: boolean | number;
|
||||
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
|
||||
}[];
|
||||
scales: Record<string, { min?: number; max?: number; distr?: number }>;
|
||||
data: unknown[][];
|
||||
bands?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!plot) {
|
||||
throw new Error(
|
||||
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
|
||||
);
|
||||
}
|
||||
|
||||
// uPlot wraps every fill in a function; calling it yields the colour or gradient.
|
||||
const resolveFill = (fill: unknown, seriesIndex: number): string | null => {
|
||||
const value =
|
||||
typeof fill === 'function'
|
||||
? (fill as (u: unknown, i: number) => unknown)(plot, seriesIndex)
|
||||
: fill;
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return value instanceof CanvasGradient ? 'gradient' : null;
|
||||
};
|
||||
|
||||
const maxOf = (values: unknown[] | undefined): number | null => {
|
||||
const finite = (values ?? []).filter(
|
||||
(value): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value),
|
||||
);
|
||||
return finite.length > 0 ? Math.max(...finite) : null;
|
||||
};
|
||||
|
||||
return {
|
||||
series: plot.series.slice(1).map((series, index) => ({
|
||||
label: series.label,
|
||||
show: series.show !== false,
|
||||
dash: series.dash,
|
||||
hasFill: series.fill != null,
|
||||
fill: resolveFill(series.fill, index + 1),
|
||||
max: maxOf(plot.data?.[index + 1]),
|
||||
pointsShow:
|
||||
typeof series.points?.show === 'function'
|
||||
? ('fn' as const)
|
||||
: (series.points?.show ?? null),
|
||||
width: series.width,
|
||||
spanGaps: series.spanGaps,
|
||||
nullCount: (plot.data?.[index + 1] ?? []).filter((v) => v === null).length,
|
||||
})),
|
||||
scales: Object.fromEntries(
|
||||
Object.entries(plot.scales).map(([key, scale]) => [
|
||||
key,
|
||||
{ min: scale.min ?? null, max: scale.max ?? null, distr: scale.distr },
|
||||
]),
|
||||
),
|
||||
pointCount: plot.data?.[0]?.length ?? 0,
|
||||
bandCount: plot.bands?.length ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function chartAt(page: Page, host: () => Locator): Chart {
|
||||
const plotArea = (): Locator => host().locator('.u-over');
|
||||
const chart: Chart = {
|
||||
host,
|
||||
plotArea,
|
||||
state: () => readUplot(host()),
|
||||
async yScale() {
|
||||
const { scales } = await chart.state();
|
||||
// Panels name the left scale 'y'; fall back to the first non-x scale.
|
||||
return (
|
||||
scales.y ??
|
||||
Object.entries(scales).find(([key]) => key !== 'x')?.[1] ?? {
|
||||
min: null,
|
||||
max: null,
|
||||
}
|
||||
);
|
||||
},
|
||||
async lineCoverage(value, color) {
|
||||
await expect(host()).toBeVisible();
|
||||
return host().evaluate(
|
||||
(node, { value, color }) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
bbox: { left: number; width: number };
|
||||
scales: Record<string, unknown>;
|
||||
valToPos: (v: number, scale: string, canvas: boolean) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const scale =
|
||||
'y' in plot.scales
|
||||
? 'y'
|
||||
: (Object.keys(plot.scales).find((key) => key !== 'x') ?? 'y');
|
||||
|
||||
const probe = document.createElement('canvas').getContext('2d');
|
||||
if (!probe) {
|
||||
throw new Error('no 2d context to resolve the colour');
|
||||
}
|
||||
probe.fillStyle = color;
|
||||
probe.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
// The line is 2px wide and anti-aliased: read the rows around it and
|
||||
// count a column when any of them is close to the colour.
|
||||
const y = Math.round(plot.valToPos(value, scale, true));
|
||||
const x = Math.round(plot.bbox.left);
|
||||
const width = Math.round(plot.bbox.width);
|
||||
const { data } = plot.ctx.getImageData(x, y - 1, width, 3);
|
||||
let hits = 0;
|
||||
for (let column = 0; column < width; column++) {
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const i = (row * width + column) * 4;
|
||||
const close =
|
||||
data[i + 3] > 0 &&
|
||||
Math.abs(data[i] - r) < 60 &&
|
||||
Math.abs(data[i + 1] - g) < 60 &&
|
||||
Math.abs(data[i + 2] - b) < 60;
|
||||
if (close) {
|
||||
hits += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return width > 0 ? hits / width : 0;
|
||||
},
|
||||
{ value, color },
|
||||
);
|
||||
},
|
||||
async expectSeriesCount(count) {
|
||||
await expect
|
||||
.poll(async () => (await chart.state()).series.length)
|
||||
.toBe(count);
|
||||
},
|
||||
async hover() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async leave() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.move(box.x - 20, box.y - 20);
|
||||
},
|
||||
async click() {
|
||||
const box = await boxOf(host(), 'the chart');
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||
},
|
||||
async clickPoint(index) {
|
||||
await expect(host()).toBeVisible();
|
||||
const point = await host().evaluate((node, at) => {
|
||||
const plot = (node as { __uplot?: unknown }).__uplot as
|
||||
| {
|
||||
data: [number[], ...(number | null)[][]];
|
||||
scales: { x: { min?: number; max?: number } };
|
||||
series: { scale?: string }[];
|
||||
over: HTMLElement;
|
||||
valToPos: (value: number, scale: string) => number;
|
||||
}
|
||||
| undefined;
|
||||
if (!plot) {
|
||||
throw new Error('no uPlot instance on the chart container');
|
||||
}
|
||||
const xs = plot.data[0];
|
||||
const { min = -Infinity, max = Infinity } = plot.scales.x;
|
||||
const visible = xs
|
||||
.map((x, i) => ({ x, i }))
|
||||
.filter(({ x }) => x >= min && x <= max);
|
||||
const i = at ?? visible[Math.floor(visible.length / 2)]?.i ?? 0;
|
||||
const y = plot.data[1]?.[i];
|
||||
if (y == null) {
|
||||
throw new Error(`no value at index ${i} of the first series`);
|
||||
}
|
||||
const over = plot.over.getBoundingClientRect();
|
||||
return {
|
||||
x: over.left + plot.valToPos(xs[i], 'x'),
|
||||
y: over.top + plot.valToPos(y, plot.series[1].scale ?? 'y'),
|
||||
};
|
||||
}, index);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
},
|
||||
async drag(fromFraction, toFraction) {
|
||||
const box = await boxOf(plotArea(), 'the plot area');
|
||||
await dragHorizontally(page, box, fromFraction, toFraction);
|
||||
},
|
||||
};
|
||||
return chart;
|
||||
}
|
||||
57
tests/e2e/drivers/panels/dom.ts
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export async function boxOf(
|
||||
locator: Locator,
|
||||
description: string,
|
||||
): Promise<Box> {
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `expected a bounding box for ${description}`).not.toBeNull();
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
/** The extra pre-mousedown move gives uPlot a cursor anchor to drag from. */
|
||||
export async function dragHorizontally(
|
||||
page: Page,
|
||||
box: Box,
|
||||
fromFraction: number,
|
||||
toFraction: number,
|
||||
): Promise<void> {
|
||||
const y = box.y + box.height / 2;
|
||||
const startX = box.x + box.width * fromFraction;
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.move(startX, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * toFraction, y, { steps: 16 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function dragBy(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
steps: number,
|
||||
): Promise<void> {
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** A visible option of whichever antd Select is open. */
|
||||
export function openSelectOption(page: Page, label: string): Locator {
|
||||
return page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText(label, { exact: true });
|
||||
}
|
||||
58
tests/e2e/drivers/panels/grid.ts
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { boxOf, dragBy } from './dom';
|
||||
|
||||
/**
|
||||
* react-grid-layout exposes no testids; `.panel-drag-handle` and
|
||||
* `.react-resizable-handle` are the class contract it is configured with
|
||||
* (draggableHandle / draggableCancel in SectionGrid).
|
||||
*/
|
||||
export interface PanelGrid {
|
||||
/** The grid item's rendered size in px. */
|
||||
size(): Promise<{ width: number; height: number }>;
|
||||
/** Drags RGL's south-east grip by the given offset. */
|
||||
resize(dx: number, dy: number): Promise<void>;
|
||||
/** Drags the header handle across its own width. */
|
||||
dragByHeader(): Promise<void>;
|
||||
}
|
||||
|
||||
export function gridAt(page: Page, root: () => Locator): PanelGrid {
|
||||
// RGL appends the resize grip to its grid item, a sibling of the panel root.
|
||||
const gridItem = (): Locator =>
|
||||
root().locator('xpath=ancestor::div[contains(@class,"react-grid-item")][1]');
|
||||
return {
|
||||
async size() {
|
||||
const { width, height } = await boxOf(gridItem(), 'the grid item');
|
||||
return { width, height };
|
||||
},
|
||||
async resize(dx, dy) {
|
||||
const grip = await boxOf(
|
||||
gridItem().locator('.react-resizable-handle'),
|
||||
'the resize handle',
|
||||
);
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: grip.x + grip.width / 2, y: grip.y + grip.height / 2 },
|
||||
{ x: grip.x + dx, y: grip.y + dy },
|
||||
12,
|
||||
);
|
||||
},
|
||||
async dragByHeader() {
|
||||
const bar = await boxOf(
|
||||
root().locator('.panel-drag-handle').first(),
|
||||
'the drag handle',
|
||||
);
|
||||
const y = bar.y + bar.height / 2;
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: bar.x + bar.width / 4, y },
|
||||
{ x: bar.x + bar.width, y },
|
||||
15,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
34
tests/e2e/drivers/panels/heatmap.ts
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export interface HeatmapPanel {
|
||||
/** The count colour bar under the grid: caption, min label, ramp, max label. */
|
||||
colorBar(): Locator;
|
||||
/** Marks the hovered cell's count on the bar. */
|
||||
colorBarMarker(): Locator;
|
||||
}
|
||||
|
||||
export interface HeatmapTooltip {
|
||||
root(): Locator;
|
||||
bucket(): Locator;
|
||||
count(): Locator;
|
||||
}
|
||||
|
||||
export function heatmapAt(root: () => Locator): HeatmapPanel {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
colorBar: () => byTestId('color-bar'),
|
||||
colorBarMarker: () => byTestId('color-bar-marker'),
|
||||
};
|
||||
}
|
||||
|
||||
export function heatmapTooltipAt(page: Page): HeatmapTooltip {
|
||||
return {
|
||||
root: () => page.getByTestId('heatmap-tooltip'),
|
||||
bucket: () => page.getByTestId('heatmap-tooltip-bucket'),
|
||||
count: () => page.getByTestId('heatmap-tooltip-count'),
|
||||
};
|
||||
}
|
||||
52
tests/e2e/drivers/panels/legend.ts
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator } from '@playwright/test';
|
||||
|
||||
export interface Legend {
|
||||
item(label: string): Locator;
|
||||
items(): Locator;
|
||||
/**
|
||||
* Clicks the row: with every series shown it solos this one, with only this
|
||||
* one shown it restores all, otherwise it toggles this one.
|
||||
*/
|
||||
solo(label: string): Promise<void>;
|
||||
/** The marker toggles just that series; disabled on the last one shown. */
|
||||
toggle(label: string): Promise<void>;
|
||||
/** The row's hover-revealed "Only" / "All" button. */
|
||||
scope(label: string): Locator;
|
||||
/** Rows are `role="switch"`; `aria-checked` carries the shown state. */
|
||||
expectShown(label: string, shown: boolean): Promise<void>;
|
||||
/** Rendered for a right legend, and a bottom one once its rows overflow. */
|
||||
search(): Locator;
|
||||
/** "Showing N of M series", rendered with the search box. */
|
||||
status(): Locator;
|
||||
}
|
||||
|
||||
export function legendAt(root: () => Locator): Legend {
|
||||
const legendItem = (label: string): Locator =>
|
||||
root().locator('[data-legend-item-id]').filter({ hasText: label });
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
|
||||
return {
|
||||
item: legendItem,
|
||||
items: () => root().locator('[data-legend-item-id]'),
|
||||
async solo(label) {
|
||||
// The label, not the row centre: the scope button overlays its tail.
|
||||
await legendItem(label).getByText(label).click();
|
||||
},
|
||||
async toggle(label) {
|
||||
await legendItem(label).locator('[data-is-legend-marker]').click();
|
||||
},
|
||||
scope: (label) => legendItem(label).locator('[data-testid^="legend-scope-"]'),
|
||||
async expectShown(label, shown) {
|
||||
await expect(legendItem(label)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
String(shown),
|
||||
);
|
||||
},
|
||||
search: () => byTestId('legend-search-input'),
|
||||
status: () => byTestId('legend-status'),
|
||||
};
|
||||
}
|
||||
27
tests/e2e/drivers/panels/markdown.ts
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** A rendered Text panel body. */
|
||||
export interface Markdown {
|
||||
root(): Locator;
|
||||
heading(name: string): Locator;
|
||||
listItems(): Locator;
|
||||
link(name: string): Locator;
|
||||
codeBlock(): Locator;
|
||||
/** "Nothing written yet", for a blank body. */
|
||||
empty(): Locator;
|
||||
}
|
||||
|
||||
export function markdownAt(root: () => Locator): Markdown {
|
||||
return {
|
||||
root,
|
||||
heading: (name) => root().getByRole('heading', { name }),
|
||||
listItems: () => root().getByRole('listitem'),
|
||||
link: (name) => root().getByRole('link', { name }),
|
||||
codeBlock: () => root().locator('pre code'),
|
||||
empty: () => root().getByTestId('text-panel-empty'),
|
||||
};
|
||||
}
|
||||
22
tests/e2e/drivers/panels/pie.ts
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator } from '@playwright/test';
|
||||
|
||||
/** The donut @visx draws: one `<path>` per visible slice, the total in the hole. */
|
||||
export interface PiePanel {
|
||||
arcs(): Locator;
|
||||
/** The total of the visible slices, centred in the donut hole. */
|
||||
total(): Locator;
|
||||
}
|
||||
|
||||
export function pieAt(root: () => Locator): PiePanel {
|
||||
// The legend's icons are SVGs too; the donut is the first one.
|
||||
const chart = (): Locator =>
|
||||
root().getByTestId('pie-chart').locator('svg').first();
|
||||
return {
|
||||
arcs: () => chart().locator('g > path'),
|
||||
total: () => chart().locator('text[text-anchor="middle"]'),
|
||||
};
|
||||
}
|
||||
105
tests/e2e/drivers/panels/table.ts
generated
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { storageKey } from '../../helpers/base-path';
|
||||
|
||||
import { boxOf, dragBy, openSelectOption } from './dom';
|
||||
|
||||
/** Local storage key column widths persist under, behind a 400ms debounce. */
|
||||
const COLUMN_WIDTHS_KEY = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS';
|
||||
|
||||
/** Header search, offered by Table and List. */
|
||||
export interface PanelSearch {
|
||||
trigger(): Locator;
|
||||
input(): Locator;
|
||||
clear(): Locator;
|
||||
open(term: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** List pagination. */
|
||||
export interface ListPager {
|
||||
page(): Locator;
|
||||
prev(): Locator;
|
||||
next(): Locator;
|
||||
/** Picks e.g. '50 / page' from the page-size select. */
|
||||
setPageSize(label: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TableColumns {
|
||||
rows(): Locator;
|
||||
header(column: string): Locator;
|
||||
/** Rendered header width in px, 0 when not laid out. */
|
||||
headerWidth(column: string): Promise<number>;
|
||||
resizeColumn(column: string, byPx: number): Promise<void>;
|
||||
/** The width persisted for this column, 0 when none. */
|
||||
storedWidth(column: string): Promise<number>;
|
||||
}
|
||||
|
||||
export function searchAt(root: () => Locator): PanelSearch {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
trigger: () => byTestId('panel-header-search-trigger'),
|
||||
input: () => byTestId('panel-header-search-input'),
|
||||
clear: () => byTestId('panel-header-search-clear'),
|
||||
async open(term) {
|
||||
await root().hover();
|
||||
await byTestId('panel-header-search-trigger').click();
|
||||
await byTestId('panel-header-search-input').fill(term);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pagerAt(page: Page, root: () => Locator): ListPager {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
return {
|
||||
page: () => byTestId('list-panel-page'),
|
||||
prev: () => byTestId('list-panel-prev'),
|
||||
next: () => byTestId('list-panel-next'),
|
||||
async setPageSize(label) {
|
||||
await byTestId('list-panel-page-size').click();
|
||||
await openSelectOption(page, label).click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function tableAt(
|
||||
page: Page,
|
||||
panelId: string,
|
||||
root: () => Locator,
|
||||
): TableColumns {
|
||||
const byTestId = (testId: string): Locator => root().getByTestId(testId);
|
||||
const header = (column: string): Locator =>
|
||||
root().locator('th').filter({ hasText: column });
|
||||
return {
|
||||
rows: () => root().locator('tbody tr.ant-table-row'),
|
||||
header,
|
||||
async headerWidth(column) {
|
||||
return (await header(column).boundingBox())?.width ?? 0;
|
||||
},
|
||||
async resizeColumn(column, byPx) {
|
||||
const grip = await boxOf(
|
||||
byTestId(`column-resize-${column}`),
|
||||
`the ${column} resize grip`,
|
||||
);
|
||||
const y = grip.y + grip.height / 2;
|
||||
await dragBy(
|
||||
page,
|
||||
{ x: grip.x + grip.width / 2, y },
|
||||
{ x: grip.x + byPx, y },
|
||||
10,
|
||||
);
|
||||
},
|
||||
storedWidth: (column) =>
|
||||
page.evaluate(
|
||||
({ key, panelId, name }) => {
|
||||
const raw = localStorage.getItem(key);
|
||||
const widths = raw ? JSON.parse(raw) : {};
|
||||
return (widths?.[panelId]?.[name] as number | undefined) ?? 0;
|
||||
},
|
||||
{ key: storageKey(COLUMN_WIDTHS_KEY), panelId, name: column },
|
||||
),
|
||||
};
|
||||
}
|
||||
539
tests/e2e/helpers/dashboard-v2-spec.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
// Typed builders for the V2 (Perses-shaped) dashboard spec — pure data, no
|
||||
// Playwright. Mirrors Go's `dashboardtypes.DashboardSpec`.
|
||||
//
|
||||
// Three server rules that surface as an opaque 400:
|
||||
// 1. POST decodes with DisallowUnknownFields — any stray key rejects it all.
|
||||
// 2. Every panel needs EXACTLY ONE query; Text needs `queries: []`.
|
||||
// 3. Panel keys match /^[a-zA-Z0-9_.-]+$/, so readable ids double as test
|
||||
// handles (`panel-actions-${id}`).
|
||||
|
||||
// ─── Kinds ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const PanelKind = {
|
||||
TimeSeries: 'signoz/TimeSeriesPanel',
|
||||
BarChart: 'signoz/BarChartPanel',
|
||||
AreaChart: 'signoz/AreaChartPanel',
|
||||
Histogram: 'signoz/HistogramPanel',
|
||||
Number: 'signoz/NumberPanel',
|
||||
PieChart: 'signoz/PieChartPanel',
|
||||
Table: 'signoz/TablePanel',
|
||||
List: 'signoz/ListPanel',
|
||||
Heatmap: 'signoz/HeatmapPanel',
|
||||
Text: 'signoz/TextPanel',
|
||||
} as const;
|
||||
|
||||
export type PanelKind = (typeof PanelKind)[keyof typeof PanelKind];
|
||||
|
||||
export type Signal = 'metrics' | 'logs' | 'traces';
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MetricAggregation {
|
||||
metricName: string;
|
||||
timeAggregation: string;
|
||||
spaceAggregation: string;
|
||||
reduceTo: string;
|
||||
}
|
||||
|
||||
/** Logs/traces aggregations are expression-shaped ("count()"), not metric-shaped. */
|
||||
export interface ExpressionAggregation {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface OrderBy {
|
||||
key: { name: string };
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface BuilderQuerySpec {
|
||||
name: string;
|
||||
signal: Signal;
|
||||
aggregations: (MetricAggregation | ExpressionAggregation)[];
|
||||
filter?: { expression: string };
|
||||
groupBy?: { name: string }[];
|
||||
order?: OrderBy[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type QueryPlugin =
|
||||
| { kind: 'signoz/BuilderQuery'; spec: BuilderQuerySpec }
|
||||
| { kind: 'signoz/PromQLQuery'; spec: { name: string; query: string } }
|
||||
| { kind: 'signoz/ClickHouseSQL'; spec: { name: string; query: string } };
|
||||
|
||||
/**
|
||||
* List reads `raw`, Heatmap `heatmap`; every other kind reads `time_series`.
|
||||
* Mixing them renders empty.
|
||||
*/
|
||||
export type QueryResultKind = 'time_series' | 'raw' | 'heatmap';
|
||||
|
||||
export interface Query {
|
||||
kind: QueryResultKind;
|
||||
spec: { plugin: QueryPlugin };
|
||||
}
|
||||
|
||||
// ─── Panel plugin specs ──────────────────────────────────────────────────
|
||||
|
||||
export interface Threshold {
|
||||
value: number;
|
||||
color: string;
|
||||
operator?:
|
||||
| 'above'
|
||||
| 'aboveOrEqual'
|
||||
| 'below'
|
||||
| 'belowOrEqual'
|
||||
| 'equal'
|
||||
| 'notEqual';
|
||||
format?: 'background' | 'text';
|
||||
unit?: string;
|
||||
label?: string;
|
||||
columnName?: string;
|
||||
}
|
||||
|
||||
export type StackMode = 'none' | 'normal' | 'percent';
|
||||
|
||||
export type HeatmapPalette =
|
||||
| 'ice'
|
||||
| 'moss'
|
||||
| 'rust'
|
||||
| 'graphite'
|
||||
| 'ember'
|
||||
| 'lagoon'
|
||||
| 'orchid'
|
||||
| 'verdant'
|
||||
| 'lava'
|
||||
| 'beacon';
|
||||
|
||||
export interface HeatmapColors {
|
||||
mode?: 'palette' | 'opacity';
|
||||
palette?: HeatmapPalette;
|
||||
scale?: 'log' | 'sqrt' | 'linear';
|
||||
/** 2–128. */
|
||||
steps?: number;
|
||||
minCount?: number | null;
|
||||
maxCount?: number | null;
|
||||
/** Opacity mode only. */
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
export interface TextPresentation {
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
/** A hex colour. */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One bag for every kind; the server's strict decoder rejects a key the kind
|
||||
* does not declare, so a mismatch fails the seed rather than rendering wrong.
|
||||
*/
|
||||
export interface PanelPluginSpec {
|
||||
visualization?: {
|
||||
timePreference?: string;
|
||||
fillSpans?: boolean;
|
||||
stackedBarChart?: boolean;
|
||||
/** Area only. */
|
||||
stack?: StackMode;
|
||||
};
|
||||
formatting?: {
|
||||
unit?: string;
|
||||
decimalPrecision?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
};
|
||||
axes?: {
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
isLogScale?: boolean;
|
||||
/** Heatmap only, and its only axis key. */
|
||||
y?: { scale: 'auto' | 'linear' | 'log' | 'symlog' };
|
||||
};
|
||||
legend?: {
|
||||
position?: 'bottom' | 'right';
|
||||
customColors?: Record<string, string>;
|
||||
};
|
||||
chartAppearance?: {
|
||||
lineStyle?: 'solid' | 'dashed';
|
||||
lineInterpolation?: 'linear' | 'spline' | 'step_before' | 'step_after';
|
||||
/** Area drops `none`. */
|
||||
fillMode?: 'none' | 'solid' | 'gradient';
|
||||
/** Area only, 0–1. */
|
||||
fillOpacity?: number;
|
||||
showPoints?: boolean;
|
||||
spanGaps?: { fillOnlyBelow?: boolean; fillLessThan?: string };
|
||||
/** Heatmap only, and its only appearance key. */
|
||||
colors?: HeatmapColors;
|
||||
};
|
||||
histogramBuckets?: {
|
||||
bucketCount?: number;
|
||||
bucketWidth?: number;
|
||||
mergeAllActiveQueries?: boolean;
|
||||
};
|
||||
selectFields?: { name: string; signal?: Signal; fieldDataType?: string }[];
|
||||
thresholds?: Threshold[];
|
||||
/** Text only. */
|
||||
mode?: 'markdown';
|
||||
text?: string;
|
||||
presentation?: TextPresentation;
|
||||
headerOptions?: { hide: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perses' link model — NOT the editor dialog's field names. The dialog labels
|
||||
* its first field "Label", but it persists as `name`; there is no `label` key
|
||||
* and the strict decoder rejects one.
|
||||
*/
|
||||
export interface ContextLink {
|
||||
name: string;
|
||||
url: string;
|
||||
tooltip?: string;
|
||||
renderVariables?: boolean;
|
||||
targetBlank?: boolean;
|
||||
}
|
||||
|
||||
export interface Panel {
|
||||
kind: 'Panel';
|
||||
spec: {
|
||||
display: { name: string; description?: string };
|
||||
links: ContextLink[];
|
||||
plugin: { kind: PanelKind; spec: PanelPluginSpec };
|
||||
/** Empty for Text, exactly one query for every other kind. */
|
||||
queries: Query[];
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GridItem {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: { $ref: string };
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
kind: 'Grid';
|
||||
spec: { display: { title: string }; items: GridItem[] };
|
||||
}
|
||||
|
||||
// ─── Variables ───────────────────────────────────────────────────────────
|
||||
|
||||
export type Variable =
|
||||
| {
|
||||
kind: 'ListVariable';
|
||||
spec: {
|
||||
name: string;
|
||||
display: { name: string };
|
||||
allowAllValue: boolean;
|
||||
allowMultiple: boolean;
|
||||
sort: 'none' | 'alphabetical-asc' | 'alphabetical-desc';
|
||||
plugin:
|
||||
| { kind: 'signoz/CustomVariable'; spec: { customValue: string } }
|
||||
| { kind: 'signoz/QueryVariable'; spec: { queryValue: string } }
|
||||
| {
|
||||
kind: 'signoz/DynamicVariable';
|
||||
spec: { name: string; signal: Signal };
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: 'TextVariable';
|
||||
spec: { name: string; display: { name: string }; value: string };
|
||||
};
|
||||
|
||||
// ─── Dashboard ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardSpec {
|
||||
display: { name: string; description?: string };
|
||||
variables: Variable[];
|
||||
panels: Record<string, Panel>;
|
||||
layouts: Layout[];
|
||||
links: ContextLink[];
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PostableDashboardV2 {
|
||||
schemaVersion: 'v6';
|
||||
/** Must be EMPTY when `generateName` is true; the server derives it. */
|
||||
name: string;
|
||||
generateName: boolean;
|
||||
tags: Tag[];
|
||||
spec: DashboardSpec;
|
||||
}
|
||||
|
||||
/** The backend pins this; a mismatch fails validation before anything else. */
|
||||
export const SCHEMA_VERSION = 'v6' as const;
|
||||
|
||||
// ─── Golden-dataset constants ────────────────────────────────────────────
|
||||
//
|
||||
// What `seed/golden` writes: four metrics over 8 services, 6h of 5-min buckets.
|
||||
|
||||
export const GOLDEN = {
|
||||
metrics: {
|
||||
calls: 'signoz_calls_total',
|
||||
latencyCount: 'signoz_latency_count',
|
||||
latencySum: 'signoz_latency_sum',
|
||||
dbLatencyCount: 'signoz_db_latency_count',
|
||||
},
|
||||
services: [
|
||||
'adservice',
|
||||
'cartservice',
|
||||
'checkoutservice',
|
||||
'currencyservice',
|
||||
'frontend',
|
||||
'paymentservice',
|
||||
'productcatalogservice',
|
||||
'shippingservice',
|
||||
],
|
||||
environment: 'production',
|
||||
/** Golden telemetry spans the last 6 hours, so windows wider than this add nothing. */
|
||||
windowHours: 6,
|
||||
} as const;
|
||||
|
||||
// ─── Query builders ──────────────────────────────────────────────────────
|
||||
|
||||
/** A rate-over-counter metrics query grouped by service — the default fixture query. */
|
||||
export function metricsQuery(options?: {
|
||||
name?: string;
|
||||
metricName?: string;
|
||||
timeAggregation?: string;
|
||||
spaceAggregation?: string;
|
||||
reduceTo?: string;
|
||||
filter?: string;
|
||||
groupBy?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: options?.metricName ?? GOLDEN.metrics.calls,
|
||||
timeAggregation: options?.timeAggregation ?? 'rate',
|
||||
spaceAggregation: options?.spaceAggregation ?? 'sum',
|
||||
reduceTo: options?.reduceTo ?? 'sum',
|
||||
},
|
||||
],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A `raw` logs/traces query for List panels — ordered newest-first like the UI seeds it. */
|
||||
export function rawQuery(options?: {
|
||||
name?: string;
|
||||
signal?: Extract<Signal, 'logs' | 'traces'>;
|
||||
filter?: string;
|
||||
limit?: number;
|
||||
}): Query {
|
||||
const signal = options?.signal ?? 'logs';
|
||||
return {
|
||||
kind: 'raw',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: [],
|
||||
order: [
|
||||
{ key: { name: 'timestamp' }, direction: 'desc' },
|
||||
{ key: { name: 'id' }, direction: 'desc' },
|
||||
],
|
||||
...(options?.limit === undefined ? {} : { limit: options.limit }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A logs/traces `count()` shaped as `time_series` — what Table and the chart
|
||||
* kinds read (unlike `rawQuery`, which only List consumes). Also the only
|
||||
* non-metrics seed that saves without picking a metric.
|
||||
*/
|
||||
export function logsCountQuery(options?: {
|
||||
name?: string;
|
||||
signal?: Extract<Signal, 'logs' | 'traces'>;
|
||||
filter?: string;
|
||||
groupBy?: string[];
|
||||
}): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/BuilderQuery',
|
||||
spec: {
|
||||
name: options?.name ?? 'A',
|
||||
signal: options?.signal ?? 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: options?.filter ?? '' },
|
||||
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
|
||||
name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A metrics builder query shaped as `heatmap`, the only result kind Heatmap reads. */
|
||||
export function heatmapQuery(
|
||||
options?: Parameters<typeof metricsQuery>[0],
|
||||
): Query {
|
||||
return { ...metricsQuery(options), kind: 'heatmap' };
|
||||
}
|
||||
|
||||
export function promqlQuery(query: string, name = 'A'): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { name, query } } },
|
||||
};
|
||||
}
|
||||
|
||||
export function clickhouseQuery(query: string, name = 'A'): Query {
|
||||
return {
|
||||
kind: 'time_series',
|
||||
spec: { plugin: { kind: 'signoz/ClickHouseSQL', spec: { name, query } } },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Panel builder ───────────────────────────────────────────────────────
|
||||
|
||||
export interface PanelOptions {
|
||||
name: string;
|
||||
description?: string;
|
||||
pluginSpec?: PanelPluginSpec;
|
||||
/**
|
||||
* Defaults to a metrics query shaped for the kind: `raw` for List, `heatmap`
|
||||
* for Heatmap. Ignored for Text, which carries none.
|
||||
*/
|
||||
query?: Query;
|
||||
links?: ContextLink[];
|
||||
}
|
||||
|
||||
function defaultQueryFor(kind: PanelKind): Query {
|
||||
if (kind === PanelKind.List) {
|
||||
return rawQuery();
|
||||
}
|
||||
if (kind === PanelKind.Heatmap) {
|
||||
return heatmapQuery();
|
||||
}
|
||||
return metricsQuery();
|
||||
}
|
||||
|
||||
export function panel(kind: PanelKind, options: PanelOptions): Panel {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: {
|
||||
name: options.name,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
links: options.links ?? [],
|
||||
plugin: { kind, spec: options.pluginSpec ?? {} },
|
||||
queries:
|
||||
kind === PanelKind.Text ? [] : [options.query ?? defaultQueryFor(kind)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dashboard composer ──────────────────────────────────────────────────
|
||||
|
||||
export interface SectionFixture {
|
||||
title: string;
|
||||
/** Keys become panel ids AND test handles, so keep them readable and unique. */
|
||||
panels: Record<string, Panel>;
|
||||
}
|
||||
|
||||
export interface DashboardFixtureOptions {
|
||||
/** Defaults to a unique value; the server derives the internal name. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
sections: SectionFixture[];
|
||||
variables?: Variable[];
|
||||
duration?: string;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
// pid distinguishes workers; the counter distinguishes seeds within one.
|
||||
let fixtureSeq = 0;
|
||||
function nextFixtureId(): string {
|
||||
fixtureSeq += 1;
|
||||
return `${process.pid}-${fixtureSeq}`;
|
||||
}
|
||||
|
||||
/** Grid is 12 columns wide (SectionGrid `cols`), so 6×6 tiles two per row. */
|
||||
const PANEL_WIDTH = 6;
|
||||
const PANEL_HEIGHT = 6;
|
||||
const GRID_COLS = 12;
|
||||
const PER_ROW = GRID_COLS / PANEL_WIDTH;
|
||||
|
||||
/** Two panels per row. Duplicate ids across sections are rejected loudly. */
|
||||
export function dashboardV2(
|
||||
options: DashboardFixtureOptions,
|
||||
): PostableDashboardV2 {
|
||||
const panels: Record<string, Panel> = {};
|
||||
const layouts: Layout[] = [];
|
||||
|
||||
for (const section of options.sections) {
|
||||
const items: GridItem[] = [];
|
||||
let index = 0;
|
||||
for (const [id, panelSpec] of Object.entries(section.panels)) {
|
||||
if (panels[id]) {
|
||||
throw new Error(`duplicate panel id ${id} in dashboard fixture`);
|
||||
}
|
||||
panels[id] = panelSpec;
|
||||
items.push({
|
||||
x: (index % PER_ROW) * PANEL_WIDTH,
|
||||
y: Math.floor(index / PER_ROW) * PANEL_HEIGHT,
|
||||
width: PANEL_WIDTH,
|
||||
height: PANEL_HEIGHT,
|
||||
content: { $ref: `#/spec/panels/${id}` },
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: section.title }, items },
|
||||
});
|
||||
}
|
||||
|
||||
const title = options.title ?? `v2-dashboard-${nextFixtureId()}`;
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
name: '',
|
||||
generateName: true,
|
||||
tags: options.tags ?? [],
|
||||
spec: {
|
||||
display: {
|
||||
name: title,
|
||||
...(options.description === undefined
|
||||
? {}
|
||||
: { description: options.description }),
|
||||
},
|
||||
variables: options.variables ?? [],
|
||||
panels,
|
||||
layouts,
|
||||
links: [],
|
||||
...(options.duration === undefined ? {} : { duration: options.duration }),
|
||||
},
|
||||
};
|
||||
}
|
||||
343
tests/e2e/helpers/query-range-mock.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// Pinned `/api/v5/query_range` payloads. Install BEFORE `page.goto`.
|
||||
//
|
||||
// Prefer golden data and assert STRUCTURE: the dataset's content is
|
||||
// deterministic but `seed/golden` rebases timestamps to `now`, so labels and
|
||||
// counts are stable while exact numbers are not. Reach for this only when the
|
||||
// backend can't promise what the test needs — error/no-data/warning states,
|
||||
// exact numbers for thresholds and units, a known series count, or stable
|
||||
// pagination.
|
||||
//
|
||||
// Envelope (double-nested, easy to get wrong), mirroring `QueryRangeV5200`:
|
||||
// { status, data: { type, meta, warning, data: { results: [...] } } }
|
||||
// `data.type` is the discriminator — a body under the wrong type renders as an
|
||||
// empty panel rather than failing loudly.
|
||||
|
||||
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
|
||||
|
||||
type RequestType = 'time_series' | 'scalar' | 'raw' | 'trace' | 'heatmap';
|
||||
|
||||
export interface QueryRangeBody {
|
||||
status: string;
|
||||
data: {
|
||||
type: RequestType;
|
||||
data: { results: unknown[] };
|
||||
meta?: Record<string, unknown>;
|
||||
warning?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(type: RequestType, results: unknown[]): QueryRangeBody {
|
||||
return { status: 'success', data: { type, data: { results } } };
|
||||
}
|
||||
|
||||
// ─── Time helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The default end of a generated series: now, floored to the minute, so series
|
||||
* built by separate calls share their timestamps instead of straddling a
|
||||
* millisecond.
|
||||
*/
|
||||
function defaultEndMs(): number {
|
||||
return Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SeriesSpec {
|
||||
labels: Record<string, string>;
|
||||
points: TimeSeriesPoint[];
|
||||
}
|
||||
|
||||
export interface HeatmapPoint {
|
||||
timestamp: number;
|
||||
/** One count per bucket bound, plus a trailing open-above overflow. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesSpec {
|
||||
labels: Record<string, string>;
|
||||
points: HeatmapPoint[];
|
||||
}
|
||||
|
||||
/** Evenly spaced epoch-ms timestamps ending at `endMs`. */
|
||||
export function timestamps(
|
||||
count: number,
|
||||
stepMinutes = 5,
|
||||
endMs = defaultEndMs(),
|
||||
): number[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => endMs - (count - 1 - i) * stepMinutes * MINUTE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** A ramp of `count` points from `from` to `to` — readable, non-flat test data. */
|
||||
export function ramp(
|
||||
count: number,
|
||||
from: number,
|
||||
to: number,
|
||||
endMs = defaultEndMs(),
|
||||
): TimeSeriesPoint[] {
|
||||
const step = count > 1 ? (to - from) / (count - 1) : 0;
|
||||
return timestamps(count, 5, endMs).map((timestamp, i) => ({
|
||||
timestamp,
|
||||
value: from + step * i,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A flat line — use when the assertion is about a specific value, not a shape. */
|
||||
export function flat(
|
||||
count: number,
|
||||
value: number,
|
||||
endMs = defaultEndMs(),
|
||||
): TimeSeriesPoint[] {
|
||||
return timestamps(count, 5, endMs).map((timestamp) => ({
|
||||
timestamp,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `count` columns of `width` cells, every count non-zero and growing with both
|
||||
* time and bucket, so any hovered cell has a value and the colour domain is known:
|
||||
* 1 … count × width.
|
||||
*/
|
||||
export function heatmapPoints(
|
||||
count: number,
|
||||
width: number,
|
||||
endMs = defaultEndMs(),
|
||||
): HeatmapPoint[] {
|
||||
return timestamps(count, 5, endMs).map((timestamp, i) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: width }, (_, j) => (i + 1) * (j + 1)),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Payload builders ────────────────────────────────────────────────────
|
||||
|
||||
export const QueryRange = {
|
||||
/** Series must nest at `results[].aggregations[].series[]`. */
|
||||
timeSeries(series: SeriesSpec[], queryName = 'A'): QueryRangeBody {
|
||||
return envelope('time_series', [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
series: series.map((s) => ({
|
||||
labels: Object.entries(s.labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: s.points.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
value: p.value,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** Rows are positional across `[...groupColumns, ...aggregationColumns]`. */
|
||||
scalar(options: {
|
||||
groupColumns?: string[];
|
||||
aggregationColumns?: string[];
|
||||
rows: (string | number)[][];
|
||||
queryName?: string;
|
||||
units?: Record<string, string>;
|
||||
}): QueryRangeBody {
|
||||
const queryName = options.queryName ?? 'A';
|
||||
const group = (options.groupColumns ?? []).map((name) => ({
|
||||
name,
|
||||
queryName,
|
||||
columnType: 'group',
|
||||
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
|
||||
}));
|
||||
const aggregation = (options.aggregationColumns ?? []).map((name, index) => ({
|
||||
name,
|
||||
queryName,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: index,
|
||||
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
|
||||
}));
|
||||
return envelope('scalar', [
|
||||
{ queryName, columns: [...group, ...aggregation], data: options.rows },
|
||||
]);
|
||||
},
|
||||
|
||||
/** List rows. `timestamp` is RFC-3339 here, not epoch ms — per the contract. */
|
||||
raw(
|
||||
rows: Record<string, unknown>[],
|
||||
options?: { queryName?: string; nextCursor?: string },
|
||||
): QueryRangeBody {
|
||||
return envelope('raw', [
|
||||
{
|
||||
queryName: options?.queryName ?? 'A',
|
||||
...(options?.nextCursor ? { nextCursor: options.nextCursor } : {}),
|
||||
rows: rows.map((data) => ({
|
||||
timestamp:
|
||||
typeof data.timestamp === 'string'
|
||||
? data.timestamp
|
||||
: new Date(Number(data.timestamp ?? 0)).toISOString(),
|
||||
data,
|
||||
})),
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Bucket bounds sit on the aggregation's `meta`; without them the panel
|
||||
* draws no grid. `series: []` is the heatmap's own no-data body.
|
||||
*/
|
||||
heatmap(
|
||||
buckets: number[],
|
||||
series: HeatmapSeriesSpec[],
|
||||
queryName = 'A',
|
||||
): QueryRangeBody {
|
||||
return envelope('heatmap', [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: { buckets },
|
||||
series: series.map((s) => ({
|
||||
labels: Object.entries(s.labels).map(([name, value]) => ({
|
||||
key: { name },
|
||||
value,
|
||||
})),
|
||||
values: s.points.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
values: p.counts,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
/** Successful but empty — drives `panel-no-data`. */
|
||||
empty(queryName = 'A'): QueryRangeBody {
|
||||
return envelope('time_series', [{ queryName, aggregations: [] }]);
|
||||
},
|
||||
|
||||
/** Successful with a warning — drives the `panel-status-warning` indicator. */
|
||||
withWarning(body: QueryRangeBody, message: string): QueryRangeBody {
|
||||
return {
|
||||
...body,
|
||||
data: {
|
||||
...body.data,
|
||||
warning: { warnings: [{ message }], message },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Route installers ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockOptions {
|
||||
/** Stop intercepting after N calls, letting later requests hit the backend. */
|
||||
times?: number;
|
||||
}
|
||||
|
||||
/** Fulfil every query_range call with one pinned body. */
|
||||
export async function mockQueryRange(
|
||||
page: Page,
|
||||
body: QueryRangeBody,
|
||||
options?: MockOptions,
|
||||
): Promise<void> {
|
||||
let served = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
if (options?.times !== undefined && served >= options.times) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
served += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Fail every query_range call — drives `panel-error`. */
|
||||
export async function mockQueryRangeError(
|
||||
page: Page,
|
||||
message = 'mocked query failure',
|
||||
options?: MockOptions & { status?: number },
|
||||
): Promise<void> {
|
||||
let served = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
if (options?.times !== undefined && served >= options.times) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
served += 1;
|
||||
await route.fulfill({
|
||||
status: options?.status ?? 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message },
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** One body per call, repeating the last — for pagination and retry flows. */
|
||||
export async function mockQueryRangeSequence(
|
||||
page: Page,
|
||||
bodies: QueryRangeBody[],
|
||||
): Promise<void> {
|
||||
let call = 0;
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
const body = bodies[Math.min(call, bodies.length - 1)];
|
||||
call += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Record request bodies without changing responses. */
|
||||
export function recordQueryRange(page: Page): {
|
||||
requests: Record<string, unknown>[];
|
||||
install: () => Promise<void>;
|
||||
} {
|
||||
const requests: Record<string, unknown>[] = [];
|
||||
return {
|
||||
requests,
|
||||
install: async () => {
|
||||
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
|
||||
const payload = route.request().postDataJSON() as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (payload) {
|
||||
requests.push(payload);
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop the interception so later navigation reaches the real backend again. */
|
||||
export async function unmockQueryRange(page: Page): Promise<void> {
|
||||
await page.unroute(QUERY_RANGE_GLOB);
|
||||
}
|
||||
@@ -37,9 +37,13 @@ export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
|
||||
// behaviour the dashboards specs assert against, so they fail as written.
|
||||
// Remove this once they are updated for the V2 dashboard.
|
||||
testIgnore: ['**/tests/dashboards/**'],
|
||||
// behaviour these specs assert against, so they fail as written. The V2
|
||||
// suite under tests/dashboards/v2/ is deliberately NOT ignored — narrow
|
||||
// this list further as the V1 specs are ported, don't re-broaden it.
|
||||
testIgnore: [
|
||||
'**/tests/dashboards/list.spec.ts',
|
||||
'**/tests/dashboards/details/**',
|
||||
],
|
||||
|
||||
// All Playwright output lands under artifacts/. One subdir per reporter
|
||||
// plus results/ for per-test artifacts (traces/screenshots/videos).
|
||||
|
||||
359
tests/e2e/testdata/v2/panels-dashboard.ts
vendored
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
GOLDEN,
|
||||
PanelKind,
|
||||
clickhouseQuery,
|
||||
dashboardV2,
|
||||
heatmapQuery,
|
||||
metricsQuery,
|
||||
panel,
|
||||
promqlQuery,
|
||||
rawQuery,
|
||||
type ContextLink,
|
||||
type PanelPluginSpec,
|
||||
type PostableDashboardV2,
|
||||
type Query,
|
||||
type Variable,
|
||||
} from '../../helpers/dashboard-v2-spec';
|
||||
|
||||
// Dashboard fixtures for the V2 suites. TypeScript rather than frozen JSON so
|
||||
// a contract change is a compile error, and panel ids stay visible to the specs
|
||||
// that use them as test handles.
|
||||
let seq = 0;
|
||||
function uniqueSuffix(): string {
|
||||
seq += 1;
|
||||
return `${process.pid}-${seq}`;
|
||||
}
|
||||
|
||||
// ─── All kinds ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALL_KINDS_PANELS = {
|
||||
timeseries: 'ts-panel',
|
||||
bar: 'bar-panel',
|
||||
histogram: 'histogram-panel',
|
||||
number: 'number-panel',
|
||||
pie: 'pie-panel',
|
||||
table: 'table-panel',
|
||||
list: 'list-panel',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One panel of every kind. Only for "every kind renders" and lazy-load — use
|
||||
* `compactDashboard` otherwise; seven panels means seven real queries.
|
||||
*/
|
||||
export function allKindsDashboard(title?: string): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: title ?? `v2-all-kinds-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Charts',
|
||||
panels: {
|
||||
[ALL_KINDS_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls by service',
|
||||
description: 'Rate of signoz_calls_total grouped by service.name',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.bar]: panel(PanelKind.BarChart, {
|
||||
name: 'Calls bar',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.histogram]: panel(PanelKind.Histogram, {
|
||||
name: 'Latency distribution',
|
||||
query: metricsQuery({
|
||||
metricName: GOLDEN.metrics.latencySum,
|
||||
groupBy: [],
|
||||
}),
|
||||
}),
|
||||
[ALL_KINDS_PANELS.pie]: panel(PanelKind.PieChart, {
|
||||
name: 'Calls share',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabular',
|
||||
panels: {
|
||||
[ALL_KINDS_PANELS.number]: panel(PanelKind.Number, {
|
||||
name: 'Total calls',
|
||||
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
|
||||
query: metricsQuery({ groupBy: [] }),
|
||||
}),
|
||||
[ALL_KINDS_PANELS.table]: panel(PanelKind.Table, {
|
||||
name: 'Calls table',
|
||||
}),
|
||||
[ALL_KINDS_PANELS.list]: panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Compact (default multi-panel fixture) ───────────────────────────────
|
||||
|
||||
export const COMPACT_PANELS = {
|
||||
timeseries: ALL_KINDS_PANELS.timeseries,
|
||||
table: ALL_KINDS_PANELS.table,
|
||||
list: ALL_KINDS_PANELS.list,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The default multi-panel fixture: three panels, two sections.
|
||||
*
|
||||
* Every panel fires its own `query_range`, and over-seeding was the dominant
|
||||
* load on the single-container stack. These three keep every distinction the
|
||||
* specs assert — TimeSeries (charts, zoom, drilldown, createAlert), Table (CSV
|
||||
* download, search), List (search, no createAlert) — and two sections keep
|
||||
* Move-to-section and the modal's select-then-confirm branch reachable.
|
||||
*/
|
||||
export function compactDashboard(title?: string): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: title ?? `v2-compact-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Charts',
|
||||
panels: {
|
||||
[COMPACT_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls by service',
|
||||
description: 'Rate of signoz_calls_total grouped by service.name',
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Tabular',
|
||||
panels: {
|
||||
[COMPACT_PANELS.table]: panel(PanelKind.Table, {
|
||||
name: 'Calls table',
|
||||
}),
|
||||
[COMPACT_PANELS.list]: panel(PanelKind.List, {
|
||||
name: 'Recent logs',
|
||||
pluginSpec: {
|
||||
selectFields: [
|
||||
{ name: 'timestamp', signal: 'logs' },
|
||||
{ name: 'body', signal: 'logs' },
|
||||
],
|
||||
},
|
||||
query: rawQuery({ signal: 'logs' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Single panel ────────────────────────────────────────────────────────
|
||||
|
||||
export const SINGLE_PANEL_ID = 'solo-panel';
|
||||
|
||||
export interface SinglePanelOptions {
|
||||
title?: string;
|
||||
kind?: PanelKind;
|
||||
panelName?: string;
|
||||
/** Plugin config (legend, axes, formatting, thresholds, …) for the panel. */
|
||||
pluginSpec?: PanelPluginSpec;
|
||||
/** Replaces the kind's default query. */
|
||||
query?: Query;
|
||||
/** Context links attached to the panel. */
|
||||
links?: ContextLink[];
|
||||
/** Attaches the `VARIABLE_NAMES` variables, for interpolation cases. */
|
||||
withVariables?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One panel, one section. Fully configurable so specs never reach into the
|
||||
* returned object — post-hoc mutation is four levels deep and breaks silently
|
||||
* when the spec shape moves.
|
||||
*/
|
||||
export function singlePanelDashboard(
|
||||
options: SinglePanelOptions = {},
|
||||
): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: options.title ?? `v2-single-${Date.now()}-${uniqueSuffix()}`,
|
||||
...(options.withVariables ? { variables: VARIABLES } : {}),
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
[SINGLE_PANEL_ID]: panel(options.kind ?? PanelKind.TimeSeries, {
|
||||
name: options.panelName ?? 'Solo panel',
|
||||
...(options.pluginSpec ? { pluginSpec: options.pluginSpec } : {}),
|
||||
...(options.query ? { query: options.query } : {}),
|
||||
...(options.links ? { links: options.links } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** No panels at all — drives the dashboard empty state and its New Panel CTA. */
|
||||
export function emptyDashboard(title?: string): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: title ?? `v2-empty-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Query-type coverage ─────────────────────────────────────────────────
|
||||
|
||||
export const QUERY_TYPE_PANELS = {
|
||||
builder: 'qb-panel',
|
||||
promql: 'promql-panel',
|
||||
clickhouse: 'chsql-panel',
|
||||
} as const;
|
||||
|
||||
/** One panel per query type, for the capability and drilldown-gating specs. */
|
||||
export function queryTypesDashboard(title?: string): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: title ?? `v2-query-types-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Query types',
|
||||
panels: {
|
||||
[QUERY_TYPE_PANELS.builder]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Builder query',
|
||||
}),
|
||||
[QUERY_TYPE_PANELS.promql]: panel(PanelKind.TimeSeries, {
|
||||
name: 'PromQL query',
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
[QUERY_TYPE_PANELS.clickhouse]: panel(PanelKind.Table, {
|
||||
name: 'ClickHouse query',
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Variables ───────────────────────────────────────────────────────────
|
||||
|
||||
export const VARIABLE_NAMES = {
|
||||
custom: 'serviceCustom',
|
||||
dynamic: 'serviceDynamic',
|
||||
} as const;
|
||||
|
||||
const VARIABLES: Variable[] = [
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: VARIABLE_NAMES.custom,
|
||||
display: { name: VARIABLE_NAMES.custom },
|
||||
allowAllValue: false,
|
||||
allowMultiple: false,
|
||||
sort: 'none',
|
||||
plugin: {
|
||||
kind: 'signoz/CustomVariable',
|
||||
// Closed list: resolves instantly, no backend round-trip.
|
||||
spec: { customValue: GOLDEN.services.slice(0, 3).join(',') },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'ListVariable',
|
||||
spec: {
|
||||
name: VARIABLE_NAMES.dynamic,
|
||||
display: { name: VARIABLE_NAMES.dynamic },
|
||||
allowAllValue: true,
|
||||
allowMultiple: true,
|
||||
sort: 'alphabetical-asc',
|
||||
plugin: {
|
||||
kind: 'signoz/DynamicVariable',
|
||||
spec: { name: 'service.name', signal: 'metrics' },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const VARIABLE_PANEL_ID = 'var-panel';
|
||||
|
||||
/** A panel filtered by a dashboard variable — backs the drilldown variable cases. */
|
||||
export function variablesDashboard(
|
||||
title?: string,
|
||||
query?: Query,
|
||||
): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: title ?? `v2-variables-${Date.now()}-${uniqueSuffix()}`,
|
||||
variables: VARIABLES,
|
||||
sections: [
|
||||
{
|
||||
title: 'Variables',
|
||||
panels: {
|
||||
[VARIABLE_PANEL_ID]: panel(PanelKind.TimeSeries, {
|
||||
name: 'Calls for $serviceCustom',
|
||||
query:
|
||||
query ??
|
||||
metricsQuery({
|
||||
filter: `service.name = $${VARIABLE_NAMES.custom}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Area / Heatmap / Text ───────────────────────────────────────────────
|
||||
|
||||
export const NEW_KINDS_PANELS = {
|
||||
area: 'area-panel',
|
||||
heatmap: 'heatmap-panel',
|
||||
text: 'md-panel',
|
||||
} as const;
|
||||
|
||||
export const NEW_KINDS_MARKDOWN =
|
||||
'## Runbook\n\nCheck the **checkout** path first.';
|
||||
|
||||
/**
|
||||
* One Area, one Heatmap and one Text panel — the kinds added after the
|
||||
* all-kinds fixture, kept apart so its seven queries don't grow.
|
||||
*/
|
||||
export function newKindsDashboard(title?: string): PostableDashboardV2 {
|
||||
return dashboardV2({
|
||||
title: title ?? `v2-new-kinds-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'New kinds',
|
||||
panels: {
|
||||
[NEW_KINDS_PANELS.area]: panel(PanelKind.AreaChart, {
|
||||
name: 'Calls area',
|
||||
}),
|
||||
[NEW_KINDS_PANELS.heatmap]: panel(PanelKind.Heatmap, {
|
||||
name: 'Calls heatmap',
|
||||
query: heatmapQuery(),
|
||||
}),
|
||||
[NEW_KINDS_PANELS.text]: panel(PanelKind.Text, {
|
||||
name: 'Runbook',
|
||||
pluginSpec: { text: NEW_KINDS_MARKDOWN },
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Invalid on purpose: Text must carry `queries: []`; the server rejects this. */
|
||||
export function textPanelWithQueryDashboard(): PostableDashboardV2 {
|
||||
const text = panel(PanelKind.Text, { name: 'Notes' });
|
||||
return dashboardV2({
|
||||
title: `v2-text-with-query-${Date.now()}-${uniqueSuffix()}`,
|
||||
sections: [
|
||||
{
|
||||
title: 'Section',
|
||||
panels: {
|
||||
[SINGLE_PANEL_ID]: {
|
||||
...text,
|
||||
spec: { ...text.spec, queries: [metricsQuery()] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
139
tests/e2e/tests/dashboards/v2/contract/actions.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
type DownloadFormat,
|
||||
expect,
|
||||
PANEL_KINDS,
|
||||
PanelAction,
|
||||
respondWithData,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
withSpareSection,
|
||||
} from '../../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: what each kind's `actions` declaration turns into on the grid — the ⋮
|
||||
// menu, its downloads and Create Alerts, drilldown, and header search. Items
|
||||
// carry no testid, so the menu matches role + visible label.
|
||||
|
||||
const DOWNLOADS: DownloadFormat[] = ['CSV', 'PNG', 'SVG'];
|
||||
|
||||
const ALWAYS_OFFERED = [
|
||||
PanelAction.view,
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
];
|
||||
|
||||
test.describe('Dashboards V2 contract — panel actions', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
const declared = [
|
||||
...(c.createAlert ? [PanelAction.createAlert] : []),
|
||||
...(c.downloads.length > 0 ? [PanelAction.download] : []),
|
||||
];
|
||||
const missing = [PanelAction.createAlert, PanelAction.download].filter(
|
||||
(label) => !declared.includes(label),
|
||||
);
|
||||
|
||||
test(`TC-01 ${c.label}: the actions menu offers the full set its declaration allows`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// A second section, so Move to section has somewhere to go.
|
||||
await dashboards.seedAndOpen(withSpareSection(c));
|
||||
const { actions } = panels.panel(SINGLE_PANEL_ID);
|
||||
await actions.open();
|
||||
|
||||
for (const label of [...ALWAYS_OFFERED, ...declared]) {
|
||||
await expect(actions.item(label)).toBeVisible();
|
||||
}
|
||||
for (const label of missing) {
|
||||
await expect(actions.item(label)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.downloads.length > 0)) {
|
||||
const missing = DOWNLOADS.filter((format) => !c.downloads.includes(format));
|
||||
|
||||
test(`TC-02 ${c.label}: Download offers ${c.downloads.join(', ')} only`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
const { actions } = panels.panel(SINGLE_PANEL_ID);
|
||||
await actions.open();
|
||||
await actions.hoverItem(PanelAction.download);
|
||||
|
||||
for (const format of c.downloads) {
|
||||
await expect(actions.item(`Download as ${format}`)).toBeVisible();
|
||||
}
|
||||
for (const format of missing) {
|
||||
await expect(actions.item(`Download as ${format}`)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.drilldown)) {
|
||||
test(`TC-03 ${c.label}: clicking the data opens the drilldown menu`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await respondWithData(queryRange, c);
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await c.click(target);
|
||||
|
||||
await expect(panels.drilldown.menu()).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-view-traces')).toBeVisible();
|
||||
await expect(panels.drilldown.item('drilldown-breakout')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => !k.drilldown)) {
|
||||
test(`TC-04 ${c.label}: clicking the data opens no menu`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await respondWithData(queryRange, c);
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await c.click(target);
|
||||
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.headerSearch)) {
|
||||
test(`TC-05 ${c.label}: offers header search`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await target.root().hover();
|
||||
await expect(target.search.trigger()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => !k.headerSearch)) {
|
||||
test(`TC-06 ${c.label}: does not offer header search`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await target.root().hover();
|
||||
await expect(target.search.trigger()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
108
tests/e2e/tests/dashboards/v2/contract/capabilities.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
clickhouseQuery,
|
||||
disabledReason,
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PANEL_KINDS,
|
||||
PanelKind,
|
||||
type PostableDashboardV2,
|
||||
promqlQuery,
|
||||
type QueryType,
|
||||
type SelectOption,
|
||||
type Signal,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: the panelKind × queryType × signal matrix as the type switcher shows
|
||||
// it. Each kind is checked against each source query: selectable, or disabled
|
||||
// with the reason getPanelTypeDisabledReason gives.
|
||||
|
||||
interface SourceQuery {
|
||||
name: string;
|
||||
queryType: QueryType;
|
||||
signal?: Signal;
|
||||
seed(): PostableDashboardV2;
|
||||
}
|
||||
|
||||
const SOURCES: SourceQuery[] = [
|
||||
{
|
||||
name: 'a metrics builder query',
|
||||
queryType: 'builder',
|
||||
signal: 'metrics',
|
||||
seed: () => singlePanelDashboard({ query: metricsQuery() }),
|
||||
},
|
||||
{
|
||||
name: 'a logs builder query',
|
||||
queryType: 'builder',
|
||||
signal: 'logs',
|
||||
seed: () => singlePanelDashboard({ query: logsCountQuery() }),
|
||||
},
|
||||
{
|
||||
name: 'a PromQL query',
|
||||
queryType: 'promql',
|
||||
seed: () =>
|
||||
singlePanelDashboard({
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'a ClickHouse query',
|
||||
queryType: 'clickhouse',
|
||||
seed: () =>
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.Table,
|
||||
query: clickhouseQuery(
|
||||
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
|
||||
),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function optionFor(options: SelectOption[], label: string): SelectOption {
|
||||
const match = options.find((option) => option.label.startsWith(label));
|
||||
expect(
|
||||
match,
|
||||
`expected a "${label}" option in the type switcher`,
|
||||
).toBeDefined();
|
||||
return match as SelectOption;
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 contract — editor capabilities matrix', () => {
|
||||
SOURCES.forEach((source, index) => {
|
||||
const number = String(index + 1).padStart(2, '0');
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const reason = disabledReason(c, source);
|
||||
if (reason === null) {
|
||||
test(`TC-${number} ${c.label}: ${source.name} keeps it selectable`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, c.label).disabled).toBe(false);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
test(`TC-${number} ${c.label}: ${source.name} disables it with a reason`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(source.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
const options = await editor.typeOptions();
|
||||
expect(optionFor(options, c.label).disabled).toBe(true);
|
||||
|
||||
await editor.typeOption(c.kind).hover();
|
||||
await expect(panels.tooltip.text(reason)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
221
tests/e2e/tests/dashboards/v2/contract/editor.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
AXIS_CONTROLS,
|
||||
emptyDashboard,
|
||||
type EditorControl,
|
||||
expect,
|
||||
kindCase,
|
||||
logsCountQuery,
|
||||
metricsQuery,
|
||||
PANEL_KINDS,
|
||||
PanelKind,
|
||||
type PanelKindCase,
|
||||
type PostableDashboardV2,
|
||||
QUERYABLE_KINDS,
|
||||
QueryTab,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
type ThresholdVariant,
|
||||
} from '../../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: what each kind declares in the editor — the picker tile, its config
|
||||
// sections, query tabs, threshold variant and the List columns editor. Tabs a
|
||||
// kind cannot use are hidden; the type switcher is the one that disables.
|
||||
|
||||
const THRESHOLD_VARIANTS: ThresholdVariant[] = ['label', 'comparison', 'table'];
|
||||
const QUERY_TABS: string[] = Object.values(QueryTab);
|
||||
|
||||
/** A kind other than `c`, so switching to `c` is a real switch. */
|
||||
function switchSourceKind(c: PanelKindCase): PanelKindCase {
|
||||
return kindCase(
|
||||
c.kind === PanelKind.TimeSeries ? PanelKind.BarChart : PanelKind.TimeSeries,
|
||||
);
|
||||
}
|
||||
|
||||
/** The source kind over a query `c` accepts: List takes no metrics. */
|
||||
function switchSource(
|
||||
c: PanelKindCase,
|
||||
source: PanelKindCase,
|
||||
): PostableDashboardV2 {
|
||||
return singlePanelDashboard({
|
||||
kind: source.kind,
|
||||
query:
|
||||
c.signals.length === 0 || c.signals.includes('metrics')
|
||||
? metricsQuery()
|
||||
: logsCountQuery(),
|
||||
});
|
||||
}
|
||||
|
||||
function partition<T>(all: T[], shown: T[]): [T[], T[]] {
|
||||
return [shown, all.filter((item) => !shown.includes(item))];
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 contract — panel editor', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
test(`TC-01 ${c.label}: the New panel drawer offers it and opens its editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_KINDS.length);
|
||||
await expect(newPanel.tile(c.kind)).toBeVisible();
|
||||
await expect(newPanel.tile(c.kind)).toContainText(c.label);
|
||||
|
||||
await newPanel.choose(c.kind);
|
||||
expect(dashboards.param('panelKind')).toBe(c.kind);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const [shownControls, hiddenControls] = partition<EditorControl>(
|
||||
['time-preference'],
|
||||
c.timePreference ? ['time-preference'] : [],
|
||||
);
|
||||
|
||||
test(`TC-02 ${c.label}: declares its sections and no others`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
for (const title of c.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
for (const title of c.forbiddenSections) {
|
||||
await expect(editor.section.toggle(title)).toHaveCount(0);
|
||||
}
|
||||
|
||||
await editor.section.expand(Section.visualization);
|
||||
for (const control of shownControls) {
|
||||
await expect(editor.control(control)).toBeVisible();
|
||||
}
|
||||
for (const control of hiddenControls) {
|
||||
await expect(editor.control(control)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.sections.includes(Section.axes))) {
|
||||
const [shown, hidden] = partition(AXIS_CONTROLS, c.axisControls);
|
||||
|
||||
test(`TC-03 ${c.label}: Axes offers ${shown.join(', ')} only`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.axes);
|
||||
for (const control of shown) {
|
||||
await expect(editor.control(control)).toBeVisible();
|
||||
}
|
||||
for (const control of hidden) {
|
||||
await expect(editor.control(control)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS) {
|
||||
const from = switchSourceKind(c);
|
||||
|
||||
test(`TC-04 ${c.label}: switching to it from ${from.label} brings its sections`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(switchSource(c, from), SINGLE_PANEL_ID);
|
||||
for (const title of from.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
|
||||
await editor.switchType(c.kind);
|
||||
for (const title of c.sections) {
|
||||
await expect(editor.section.toggle(title)).toBeVisible();
|
||||
}
|
||||
for (const title of c.forbiddenSections) {
|
||||
await expect(editor.section.toggle(title)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
const [shown, hidden] = partition(QUERY_TABS, c.queryTabs);
|
||||
|
||||
test(`TC-05 ${c.label}: offers only the query tabs it supports`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
for (const tab of shown) {
|
||||
await expect(editor.query.tab(tab)).toBeVisible();
|
||||
}
|
||||
for (const tab of hidden) {
|
||||
await expect(editor.query.tab(tab)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.queryless)) {
|
||||
test(`TC-06 ${c.label}: has no query builder, tabs or columns editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.text.pane()).toBeVisible();
|
||||
await expect(editor.query.builder()).toHaveCount(0);
|
||||
for (const tab of QUERY_TABS) {
|
||||
await expect(editor.query.tab(tab)).toHaveCount(0);
|
||||
}
|
||||
await expect(config.listColumns.editor()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.thresholdVariant !== null)) {
|
||||
const variant = c.thresholdVariant as ThresholdVariant;
|
||||
const others = THRESHOLD_VARIANTS.filter((other) => other !== variant);
|
||||
|
||||
test(`TC-07 ${c.label}: Thresholds uses the ${variant} variant`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.thresholds);
|
||||
await expect(config.thresholds.addButton(variant)).toBeVisible();
|
||||
for (const other of others) {
|
||||
await expect(config.thresholds.addButton(other)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of PANEL_KINDS.filter((k) => k.columnsEditor)) {
|
||||
test(`TC-08 ${c.label}: has the columns editor`, async ({
|
||||
dashboards,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
await expect(config.listColumns.editor()).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS.filter((k) => !k.columnsEditor)) {
|
||||
test(`TC-09 ${c.label}: has no columns editor`, async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(c.seed(), SINGLE_PANEL_ID);
|
||||
await expect(editor.query.builder()).toBeVisible();
|
||||
await expect(config.listColumns.editor()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
123
tests/e2e/tests/dashboards/v2/contract/rendering.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
belowTheFold,
|
||||
expect,
|
||||
noDataOf,
|
||||
PANEL_KINDS,
|
||||
PanelMessageText,
|
||||
QUERYABLE_KINDS,
|
||||
type QueryRangeBody,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
} from '../../../../drivers/panel-kinds';
|
||||
|
||||
// Scope: every kind mounts its renderer, fetches lazily, and shows the shared
|
||||
// PanelBody states. States are mock-only: a healthy stack always returns data.
|
||||
|
||||
function required(body: QueryRangeBody | null): QueryRangeBody {
|
||||
expect(body, 'a queryable kind declares mock bodies').not.toBeNull();
|
||||
return body as QueryRangeBody;
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 contract — rendering', () => {
|
||||
for (const c of PANEL_KINDS) {
|
||||
test(`TC-01 ${c.label}: mounts its own renderer`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await target.waitRendered(c.kind);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-02 ${c.label}: does not query below the fold until scrolled into view`, async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(belowTheFold(c));
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'false',
|
||||
);
|
||||
await expect(target.renderer(c.kind)).toHaveCount(0);
|
||||
|
||||
await target.scrollIntoView();
|
||||
await expect(target.visibility()).toHaveAttribute(
|
||||
'data-panel-visible',
|
||||
'true',
|
||||
);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-03 ${c.label}: an empty result offers Extend time range as primary and Retry as secondary`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(required(c.emptyBody()));
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
const noData = noDataOf(c, target);
|
||||
await expect(noData.root()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.noDataTitle)).toBeVisible();
|
||||
await expect(noData.action()).toHaveText(PanelMessageText.extendAction);
|
||||
await expect(noData.secondaryAction()).toHaveText(
|
||||
PanelMessageText.retryAction,
|
||||
);
|
||||
await expect(target.heatmap.colorBar()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-04 ${c.label}: a failing query shows the error state and Retry refetches`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// Fail every call, then swap to success before Retry: react-query retries
|
||||
// on its own, so "fail once then succeed" never surfaces the error state.
|
||||
await queryRange.fail('querier exploded');
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.error()).toBeVisible();
|
||||
await expect(target.statusError()).toBeVisible();
|
||||
await expect(target.text(PanelMessageText.errorTitle)).toBeVisible();
|
||||
await expect(target.text('querier exploded')).toBeVisible();
|
||||
|
||||
await queryRange.reset();
|
||||
await queryRange.respond(required(c.body()));
|
||||
|
||||
await target.errorAction().click();
|
||||
await expect(target.error()).toHaveCount(0);
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of QUERYABLE_KINDS) {
|
||||
test(`TC-05 ${c.label}: an in-flight first query shows the loading state`, async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
// LOADING, not `panel-refetching` — the refetch spinner needs prior data.
|
||||
const held = await queryRange.hold(required(c.body()));
|
||||
await dashboards.seedAndOpen(c.seed());
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.loading()).toBeVisible();
|
||||
|
||||
held.release();
|
||||
await expect(target.renderer(c.kind)).toBeVisible();
|
||||
await expect(target.loading()).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
139
tests/e2e/tests/dashboards/v2/dashboard/actions.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelAction,
|
||||
compactDashboard,
|
||||
expect,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: what the panel ⋮ menu's actions do — new-tab alert, download, and the
|
||||
// mutating ones reaching the spec — plus the dashboard lock gating them.
|
||||
//
|
||||
// Items carry no testid, so everything matches role + visible label.
|
||||
|
||||
test.describe('Dashboards V2 — panel actions', () => {
|
||||
test('TC-01 Create Alerts opens the alert builder in a new tab', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const popup = panels.nextTab();
|
||||
await panels
|
||||
.panel(COMPACT_PANELS.timeseries)
|
||||
.actions.run(PanelAction.createAlert);
|
||||
const alertTab = await popup;
|
||||
await expect(alertTab).toHaveURL(/\/alerts\/new/);
|
||||
await alertTab.close();
|
||||
});
|
||||
|
||||
// Chromium-only: headless Firefox/WebKit don't surface the canvas blob as a
|
||||
// Playwright download event. CSV and SVG are unaffected.
|
||||
test('TC-02 Download as PNG produces a file', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'headless Firefox/WebKit do not emit a download event for the canvas blob',
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await target.scrollIntoView();
|
||||
|
||||
const download = panels.nextDownload();
|
||||
await target.actions.download('PNG');
|
||||
const file = await download;
|
||||
expect(file.suggestedFilename()).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
test('TC-03 Clone adds a second panel and persists it', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const before = await dashboards.get(id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await panels.panel(COMPACT_PANELS.timeseries).actions.run(PanelAction.clone);
|
||||
|
||||
await expect
|
||||
.poll(async () => Object.keys((await dashboards.get(id)).spec.panels).length)
|
||||
.toBe(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-04 Delete panel removes the panel and its layout item', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const list = panels.panel(COMPACT_PANELS.list);
|
||||
await list.actions.run(PanelAction.delete);
|
||||
await expect(dashboards.text('Delete panel?')).toBeVisible();
|
||||
await dashboards.confirmDelete().click();
|
||||
|
||||
await expect(list.root()).toHaveCount(0);
|
||||
|
||||
// Optimistic: the panel leaves the DOM before the PATCH lands.
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await dashboards.get(id)).spec.panels[COMPACT_PANELS.list],
|
||||
)
|
||||
.toBeUndefined();
|
||||
|
||||
// The grid item must go too, or a dangling $ref renders an empty tile.
|
||||
const after = await dashboards.get(id);
|
||||
const refs = after.spec.layouts.flatMap((layout) =>
|
||||
layout.spec.items.map((item) => item.content.$ref),
|
||||
);
|
||||
expect(refs).not.toContain(`#/spec/panels/${COMPACT_PANELS.list}`);
|
||||
});
|
||||
|
||||
test('TC-05 Move to section relocates the panel between sections', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await panels.panel(COMPACT_PANELS.timeseries).actions.moveTo('Tabular');
|
||||
|
||||
const ref = `#/spec/panels/${COMPACT_PANELS.timeseries}`;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { spec } = await dashboards.get(id);
|
||||
return spec.layouts
|
||||
.filter((layout) =>
|
||||
layout.spec.items.some((item) => item.content.$ref === ref),
|
||||
)
|
||||
.map((layout) => layout.spec.display.title);
|
||||
})
|
||||
.toEqual(['Tabular']);
|
||||
});
|
||||
|
||||
test('TC-06 a locked dashboard disables the mutating actions', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(compactDashboard());
|
||||
await dashboards.setLocked(id, true);
|
||||
await dashboards.open(id);
|
||||
|
||||
const { actions } = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await actions.open();
|
||||
|
||||
// View and Download don't mutate, so they stay available.
|
||||
await expect(actions.item(PanelAction.view)).toBeEnabled();
|
||||
|
||||
for (const label of [
|
||||
PanelAction.edit,
|
||||
PanelAction.clone,
|
||||
PanelAction.move,
|
||||
PanelAction.delete,
|
||||
]) {
|
||||
await expect(actions.item(label)).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
207
tests/e2e/tests/dashboards/v2/dashboard/drilldown.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
type Dashboards,
|
||||
PanelKind,
|
||||
type Panels,
|
||||
QUERY_TYPE_PANELS,
|
||||
QueryRange,
|
||||
type QueryRangeMock,
|
||||
SINGLE_PANEL_ID,
|
||||
VARIABLE_NAMES,
|
||||
VARIABLE_PANEL_ID,
|
||||
expect,
|
||||
metricsQuery,
|
||||
queryTypesDashboard,
|
||||
ramp,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
variablesDashboard,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: the drilldown ContextMenu — dismissal, navigation, variables, and
|
||||
// the query types deliberately excluded. Mocked so a click lands on a known series.
|
||||
|
||||
const ONE_SERIES = QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
|
||||
]);
|
||||
|
||||
async function openChartDrilldown(
|
||||
panels: Panels,
|
||||
panelId: string,
|
||||
): Promise<void> {
|
||||
await panels.panel(panelId).chart.clickPoint();
|
||||
await expect(panels.drilldown.menu()).toBeVisible();
|
||||
}
|
||||
|
||||
async function seedChartPanel({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}: {
|
||||
dashboards: Dashboards;
|
||||
queryRange: QueryRangeMock;
|
||||
panels: Panels;
|
||||
}): Promise<string> {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await expect(
|
||||
panels.panel(SINGLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
return id;
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — panel drilldown', () => {
|
||||
test('TC-01 clicking the backdrop closes the menu', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
// Backdrop click. Escape only works when the backdrop holds focus.
|
||||
await panels.drilldown.dismiss();
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 View in Logs navigates to the logs explorer', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
// Disabled while the drilldown query resolves.
|
||||
const viewLogs = panels.drilldown.item('drilldown-view-logs');
|
||||
await expect(viewLogs).toBeEnabled();
|
||||
|
||||
// safeNavigate uses `{ newTab: true }`, so the dashboard stays put.
|
||||
const popup = panels.nextTab();
|
||||
await viewLogs.click();
|
||||
const logsTab = await popup;
|
||||
|
||||
await logsTab.waitForURL(/\/logs\/logs-explorer/);
|
||||
// The clicked series carries across as a composite query.
|
||||
expect(
|
||||
new URL(logsTab.url()).searchParams.get('compositeQuery'),
|
||||
).toBeTruthy();
|
||||
await logsTab.close();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\//);
|
||||
});
|
||||
|
||||
test('TC-03 Breakout opens a submenu and the back arrow returns', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await seedChartPanel({ dashboards, queryRange, panels });
|
||||
await openChartDrilldown(panels, SINGLE_PANEL_ID);
|
||||
|
||||
await panels.drilldown.item('drilldown-breakout').click();
|
||||
const back = panels.drilldown.back('breakout');
|
||||
await expect(back).toBeVisible();
|
||||
await expect(panels.drilldown.breakoutSearch()).toBeVisible();
|
||||
|
||||
// Back returns to the aggregate menu.
|
||||
await back.click();
|
||||
await expect(panels.drilldown.item('drilldown-view-logs')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 the Dashboard Variables submenu offers set and create', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(variablesDashboard());
|
||||
await expect(
|
||||
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
|
||||
await panels.drilldown.item('drilldown-dashboard-variables').click();
|
||||
|
||||
// `service.name` is grouped-by and already has a variable, so Set is offered.
|
||||
await expect(panels.drilldown.item('drilldown-var-set')).toBeVisible();
|
||||
await expect(panels.drilldown.back('var')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 setting a variable from the menu updates the variables bar', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(variablesDashboard());
|
||||
await expect(
|
||||
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
|
||||
// Set binds the clicked group-by field, so it targets the dynamic
|
||||
// variable on `service.name`; the custom one already defaults to adservice.
|
||||
const dynamic = dashboards.variable(VARIABLE_NAMES.dynamic);
|
||||
await expect(dynamic).not.toContainText('adservice');
|
||||
|
||||
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
|
||||
await panels.drilldown.item('drilldown-dashboard-variables').click();
|
||||
await panels.drilldown.item('drilldown-var-set').click();
|
||||
|
||||
await expect(dynamic).toContainText('adservice');
|
||||
});
|
||||
|
||||
test('TC-06 creating a variable from the menu patches the dashboard spec', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.timeSeries([
|
||||
{
|
||||
labels: { 'k8s.namespace.name': 'signoz-adservice' },
|
||||
points: ramp(12, 2, 8),
|
||||
},
|
||||
]),
|
||||
);
|
||||
// No matching variable for this field, so the menu offers Create.
|
||||
const id = await dashboards.seedAndOpen(
|
||||
variablesDashboard(
|
||||
undefined,
|
||||
metricsQuery({ groupBy: ['k8s.namespace.name'] }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
panels.panel(VARIABLE_PANEL_ID).renderer(PanelKind.TimeSeries),
|
||||
).toBeVisible();
|
||||
|
||||
await openChartDrilldown(panels, VARIABLE_PANEL_ID);
|
||||
await panels.drilldown.item('drilldown-dashboard-variables').click();
|
||||
await panels.drilldown.item('drilldown-var-create').click();
|
||||
|
||||
// Create persists a DYNAMIC variable into spec.variables.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await dashboards.get(id)).spec.variables.some(
|
||||
(variable) => variable.spec.name === 'k8s.namespace.name',
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-07 a non-builder query opens no menu', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(ONE_SERIES);
|
||||
await dashboards.seedAndOpen(queryTypesDashboard());
|
||||
|
||||
const target = panels.panel(QUERY_TYPE_PANELS.promql);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// Gated to QUERY_BUILDER queries.
|
||||
await target.chart.click();
|
||||
await expect(panels.drilldown.menu()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
141
tests/e2e/tests/dashboards/v2/dashboard/from-scratch.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
PANEL_TYPE_TILES,
|
||||
PanelKind,
|
||||
PATH,
|
||||
test,
|
||||
} from '../../../../drivers/dashboard-list';
|
||||
|
||||
// Scope: a dashboard built entirely through the UI — the list page's Blank
|
||||
// create flow, one panel of every queryable kind, and deletion from the list.
|
||||
|
||||
/** Text carries no query. */
|
||||
const QUERYABLE = PANEL_TYPE_TILES.filter(
|
||||
(tile) => tile.kind !== PanelKind.Text,
|
||||
);
|
||||
|
||||
/** List seeds a logs query that saves as-is; every other kind seeds metrics. */
|
||||
const METRICS_TILES = QUERYABLE.filter((tile) => tile.kind !== PanelKind.List);
|
||||
|
||||
function uniqueName(label: string): string {
|
||||
return `e2e ${label} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
}
|
||||
|
||||
test.describe('Dashboards V2 — dashboard from scratch', () => {
|
||||
test('TC-01 a blank dashboard is created from the list page', async ({
|
||||
authedPage: page,
|
||||
dashboardList,
|
||||
dashboards,
|
||||
}) => {
|
||||
const name = uniqueName('blank');
|
||||
await dashboardList.open();
|
||||
|
||||
const id = await dashboardList.create({
|
||||
name,
|
||||
description: 'Built through the UI',
|
||||
tags: ['team:e2e', 'env:test'],
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(PATH.onDashboard(id));
|
||||
await expect(dashboards.title()).toHaveText(name);
|
||||
await expect(dashboards.emptyAddPanel()).toBeVisible();
|
||||
|
||||
const saved = await dashboards.get(id);
|
||||
expect(saved.spec.display.name).toBe(name);
|
||||
expect(saved.spec.display.description).toBe('Built through the UI');
|
||||
expect(saved.tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: 'team', value: 'e2e' },
|
||||
{ key: 'env', value: 'test' },
|
||||
]),
|
||||
);
|
||||
expect(saved.tags).toHaveLength(2);
|
||||
expect(Object.keys(saved.spec.panels)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 Create is disabled until the dashboard has a name', async ({
|
||||
dashboardList,
|
||||
}) => {
|
||||
await dashboardList.open();
|
||||
await dashboardList.newDashboardButton().click();
|
||||
|
||||
await dashboardList.nameInput().fill(' ');
|
||||
await expect(dashboardList.submit()).toBeDisabled();
|
||||
|
||||
await dashboardList.nameInput().fill(uniqueName('named'));
|
||||
await expect(dashboardList.submit()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-03 one panel of every queryable kind is added and persists', async ({
|
||||
authedPage: page,
|
||||
dashboardList,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboardList.open();
|
||||
const id = await dashboardList.create({ name: uniqueName('all kinds') });
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await editor.title().fill('List panel');
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(panels.titles().filter({ hasText: 'List panel' })).toBeVisible();
|
||||
|
||||
for (const { kind, label } of METRICS_TILES) {
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(kind);
|
||||
expect(dashboards.param('panelKind')).toBe(kind);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.title().fill(`${label} panel`);
|
||||
await editor.query.selectMetric(GOLDEN.metrics.calls);
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: `${label} panel` }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dashboards.reload();
|
||||
for (const { label } of QUERYABLE) {
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: `${label} panel` }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(panels.titles()).toHaveCount(QUERYABLE.length);
|
||||
|
||||
const saved = await dashboards.get(id);
|
||||
const persisted = Object.values(saved.spec.panels);
|
||||
expect(persisted.map((panel) => panel.spec.plugin.kind).sort()).toEqual(
|
||||
QUERYABLE.map((tile) => tile.kind).sort(),
|
||||
);
|
||||
for (const panel of persisted) {
|
||||
expect(panel.spec.queries).toHaveLength(1);
|
||||
}
|
||||
const items = saved.spec.layouts.flatMap((layout) => layout.spec.items);
|
||||
expect(items).toHaveLength(QUERYABLE.length);
|
||||
});
|
||||
|
||||
test('TC-04 a dashboard is deleted from the list and disappears', async ({
|
||||
dashboardList,
|
||||
dashboards,
|
||||
}) => {
|
||||
const name = uniqueName('doomed');
|
||||
await dashboardList.open();
|
||||
const id = await dashboardList.create({ name });
|
||||
|
||||
await dashboardList.open(name);
|
||||
await expect(dashboardList.rowTitle(name)).toBeVisible();
|
||||
|
||||
await dashboardList.delete(name);
|
||||
await expect(dashboardList.rowTitle(name)).toHaveCount(0);
|
||||
|
||||
await dashboardList.open(name);
|
||||
await expect(dashboardList.rowTitle(name)).toHaveCount(0);
|
||||
await expect(dashboards.get(id)).rejects.toThrow(/404/);
|
||||
});
|
||||
});
|
||||
106
tests/e2e/tests/dashboards/v2/dashboard/layout.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelKind,
|
||||
compactDashboard,
|
||||
expect,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: grid layout mutations — drag, resize, and the guarantee that the
|
||||
// header's action cluster never starts a drag.
|
||||
|
||||
test.describe('Dashboards V2 — grid layout', () => {
|
||||
test('TC-01 resizing a panel persists its new size', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
const before = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
|
||||
|
||||
await target.grid.resize(60, 90);
|
||||
|
||||
// The grid persists on resize-stop, so the spec is the source of truth
|
||||
// rather than the rendered pixel size.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
|
||||
return after.height > before.height || after.width > before.width;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-02 a resized layout survives a reload', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await target.grid.resize(0, 90);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await dashboards.gridItem(id, COMPACT_PANELS.timeseries)).height,
|
||||
)
|
||||
.toBeGreaterThan(6);
|
||||
|
||||
const persisted = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
|
||||
const rendered = await target.grid.size();
|
||||
await dashboards.reload();
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
expect(await dashboards.gridItem(id, COMPACT_PANELS.timeseries)).toEqual(
|
||||
persisted,
|
||||
);
|
||||
// The page must lay out from the saved size, not a stale default.
|
||||
await expect
|
||||
.poll(async () => (await target.grid.size()).height)
|
||||
.toBeCloseTo(rendered.height, -1);
|
||||
});
|
||||
|
||||
test('TC-03 opening the actions menu does not start a drag', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
const before = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
|
||||
|
||||
// The ⋮ button sits inside the drag handle but is marked `panel-no-drag`
|
||||
// and stops pointerdown, so opening the menu must leave the grid alone.
|
||||
await target.actions.open();
|
||||
await target.actions.close();
|
||||
|
||||
expect(await dashboards.gridItem(id, COMPACT_PANELS.timeseries)).toEqual(
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-04 dragging by the header moves the panel within its section', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
const before = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
|
||||
|
||||
// A full tile-width right, so the swap is unambiguous.
|
||||
await target.grid.dragByHeader();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await dashboards.gridItem(id, COMPACT_PANELS.timeseries);
|
||||
return after.x !== before.x || after.y !== before.y;
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
87
tests/e2e/tests/dashboards/v2/dashboard/panel-header.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelKind,
|
||||
QueryRange,
|
||||
compactDashboard,
|
||||
dashboardV2,
|
||||
expect,
|
||||
panel,
|
||||
ramp,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: the panel header's identity and status affordances.
|
||||
|
||||
test.describe('Dashboards V2 — panel header', () => {
|
||||
test('TC-01 header shows the panel title and description tooltip', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.title()).toHaveText('Calls by service');
|
||||
|
||||
const info = target.infoIcon();
|
||||
await expect(info).toBeVisible();
|
||||
await info.hover();
|
||||
await expect(
|
||||
panels.tooltip.text('Rate of signoz_calls_total grouped by service.name'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(panels.panel(COMPACT_PANELS.table).infoIcon()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 a query warning surfaces the warning indicator and its popover', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.withWarning(
|
||||
QueryRange.timeSeries([
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
|
||||
]),
|
||||
'sampled result',
|
||||
),
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const warning = panels.panel(COMPACT_PANELS.timeseries).statusWarning();
|
||||
await expect(warning).toBeVisible();
|
||||
await expect(warning).toHaveAttribute('aria-label', 'Panel warning');
|
||||
|
||||
// Hover tooltip, not a popover; Radix renders the content twice.
|
||||
await warning.hover();
|
||||
await expect(panels.tooltip.statusContent()).toContainText('sampled result');
|
||||
});
|
||||
|
||||
test('TC-03 the time-preference pill reflects a panel-scoped window', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// The pill only renders when a panel opts OUT of the global range, so the
|
||||
// unpinned neighbour is the control.
|
||||
await dashboards.seedAndOpen(
|
||||
dashboardV2({
|
||||
sections: [
|
||||
{
|
||||
title: 'Time preference',
|
||||
panels: {
|
||||
pinned: panel(PanelKind.TimeSeries, {
|
||||
name: 'Pinned to 15m',
|
||||
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
|
||||
}),
|
||||
global: panel(PanelKind.TimeSeries, {
|
||||
name: 'Follows the dashboard',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(panels.panel('pinned').timePreference()).toBeVisible();
|
||||
await expect(panels.panel('global').timePreference()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
53
tests/e2e/tests/dashboards/v2/dashboard/states.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
PanelMessageText,
|
||||
QueryRange,
|
||||
SINGLE_PANEL_ID,
|
||||
expect,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: the no-data state's Extend time range recovery.
|
||||
//
|
||||
// Mock-only: a healthy stack returns data, so no-data is unreachable live.
|
||||
|
||||
test.describe('Dashboards V2 — panel states', () => {
|
||||
test('TC-01 Extend time range widens the dashboard window', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.empty());
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const extend = panels.panel(SINGLE_PANEL_ID).noDataAction();
|
||||
await expect(extend).toBeVisible();
|
||||
|
||||
const refetch = queryRange.nextRequest();
|
||||
await extend.click();
|
||||
await refetch;
|
||||
|
||||
// Widening walks the URL-backed zoom-out ladder.
|
||||
await expect.poll(() => dashboards.param('relativeTime')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('TC-02 a panel pinned to a fixed window cannot extend, so Retry is primary', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(QueryRange.empty());
|
||||
|
||||
// A panel-scoped window means extend has nothing to widen.
|
||||
await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({
|
||||
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.noData()).toBeVisible();
|
||||
await expect(target.noDataAction()).toHaveText(PanelMessageText.retryAction);
|
||||
await expect(target.noDataSecondaryAction()).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
177
tests/e2e/tests/dashboards/v2/dashboard/view-modal.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
PATH,
|
||||
PanelAction,
|
||||
PanelKind,
|
||||
SINGLE_PANEL_ID,
|
||||
expect,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the View modal, and its two-way handoff with the panel editor.
|
||||
//
|
||||
// Both directions carry LIVE, unsaved state — modal → editor via router state
|
||||
// (`editSpec`), editor → modal via sessionStorage + `compositeQuery`. Losing
|
||||
// either silently discards in-progress work, so TC-08/TC-09 assert the carried
|
||||
// state AND that nothing was persisted.
|
||||
|
||||
const onEditor = (id: string): RegExp => new RegExp(`/dashboard/${id}/panel/`);
|
||||
|
||||
test.describe('Dashboards V2 — View modal', () => {
|
||||
test('TC-01 View opens the modal and reflects it in the URL', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
const modal = await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(panels.view.refresh()).toBeVisible();
|
||||
expect(dashboards.param('expandedWidgetId')).toBe(SINGLE_PANEL_ID);
|
||||
});
|
||||
|
||||
test('TC-02 the modal opens directly from a deep link', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.visit(
|
||||
`${PATH.dashboard(id)}?expandedWidgetId=${SINGLE_PANEL_ID}&graphType=graph`,
|
||||
);
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Refresh re-issues the query', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
const refetch = queryRange.nextRequest();
|
||||
await panels.view.refresh().click();
|
||||
const request = await refetch;
|
||||
expect(request.method()).toBe('POST');
|
||||
});
|
||||
|
||||
test('TC-04 switching the panel type in the modal does not persist', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchType('Table');
|
||||
await expect(panels.view.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
await panels.back();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-05 Switch to Edit Mode hands off to the panel editor', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchToEdit().click();
|
||||
|
||||
await page.waitForURL(onEditor(id));
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 a locked panel opens in View but offers no Switch to Edit', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
// `canSwitchToEdit = canEditDashboard && !isLocked`, but View itself is not
|
||||
// role-gated — so the modal opens and only the handoff button is disabled.
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
await dashboards.setLocked(id, true);
|
||||
await dashboards.open(id);
|
||||
|
||||
await panels.panel(SINGLE_PANEL_ID).actions.run(PanelAction.view);
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
await expect(panels.view.switchToEdit()).toBeDisabled();
|
||||
await expect(panels.view.refresh()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 View → Edit → View round-trips with no changes', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({ panelName: 'Round trip' }),
|
||||
);
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchToEdit().click();
|
||||
await page.waitForURL(onEditor(id));
|
||||
await expect(editor.title()).toHaveValue('Round trip');
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
|
||||
await editor.switchToView().click();
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Round trip',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-08 an unsaved change in the modal carries into the editor', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
|
||||
await panels.view.switchType('Table');
|
||||
await expect(panels.view.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
await panels.view.switchToEdit().click();
|
||||
await page.waitForURL(onEditor(id));
|
||||
|
||||
// Editor opens on the MODIFIED panel, and still nothing is committed.
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-09 an unsaved change in the editor carries back into the modal', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.title().fill('Edited but not saved');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
|
||||
await editor.switchToView().click();
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
await expect(panels.view.dialog()).toContainText('Edited but not saved');
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
});
|
||||
152
tests/e2e/tests/dashboards/v2/dashboard/zoom.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
PanelKind,
|
||||
QueryRange,
|
||||
SINGLE_PANEL_ID,
|
||||
compactDashboard,
|
||||
expect,
|
||||
ramp,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panels';
|
||||
|
||||
// Scope: brush-select zoom and the window it writes to.
|
||||
//
|
||||
// Mocked, not golden-backed: these need a rendered CANVAS to drag across, and a
|
||||
// query issued before ClickHouse has the re-seeded rows queryable returns empty
|
||||
// — "No Data", no canvas, intermittent timeout. Nothing here asserts a value.
|
||||
|
||||
/**
|
||||
* WebKit only, for the tests that need a drag to actually ZOOM.
|
||||
*
|
||||
* Mid-drag under WebKit the live uPlot instance shows `cursor.left` updating
|
||||
* (mousemove arrives) but `cursor.drag._x === false` and `select.width === 0` —
|
||||
* the synthetic mousedown never starts a drag. Chromium and Firefox zoom fine,
|
||||
* so this looks like input synthesis rather than the app, though unproven.
|
||||
* TC-03/TC-05 still run here: they assert NO zoom, which stays meaningful.
|
||||
*/
|
||||
function skipDragZoomOnWebkit(browserName: string): void {
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'WebKit: synthetic mousedown does not start a uPlot drag (cursor.drag._x stays false)',
|
||||
);
|
||||
}
|
||||
|
||||
const ZOOM_SERIES = [
|
||||
{ labels: { 'service.name': 'adservice' }, points: ramp(24, 2, 9) },
|
||||
];
|
||||
|
||||
test.describe('Dashboards V2 — panel zoom', () => {
|
||||
test.beforeEach(async ({ queryRange }) => {
|
||||
await queryRange.respond(QueryRange.timeSeries(ZOOM_SERIES));
|
||||
});
|
||||
|
||||
test('TC-01 brush-select writes an absolute window into the URL', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
browserName,
|
||||
}) => {
|
||||
skipDragZoomOnWebkit(browserName);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
const xSpan = async (): Promise<number> => {
|
||||
const { x } = (await target.chart.state()).scales;
|
||||
return (x.max ?? 0) - (x.min ?? 0);
|
||||
};
|
||||
const before = await xSpan();
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
await target.chart.drag(0.3, 0.7);
|
||||
|
||||
// An absolute range in the URL is what makes the zoom shareable.
|
||||
await expect
|
||||
.poll(() =>
|
||||
Boolean(dashboards.param('startTime') && dashboards.param('endTime')),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(Number(dashboards.param('endTime'))).toBeGreaterThan(
|
||||
Number(dashboards.param('startTime')),
|
||||
);
|
||||
|
||||
// The chart must actually show the narrower window, not just the URL. The
|
||||
// drag covers 40% of the plot, so the visible span shrinks well below 60%.
|
||||
await expect.poll(xSpan).toBeLessThan(before * 0.6);
|
||||
expect(await xSpan()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('TC-02 zooming one panel refetches every panel on the dashboard', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
browserName,
|
||||
}) => {
|
||||
skipDragZoomOnWebkit(browserName);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
const target = panels.panel(COMPACT_PANELS.timeseries);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// Zoom updates the GLOBAL interval, so siblings must re-query.
|
||||
const queries = queryRange.countRequests();
|
||||
|
||||
await target.chart.drag(0.25, 0.75);
|
||||
await expect.poll(() => queries.count).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('TC-03 a zero-width drag does not zoom', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// A width-0 selection is dropped, so click-to-drilldown never zooms.
|
||||
await target.chart.drag(0.5, 0.5);
|
||||
|
||||
expect(dashboards.param('startTime')).toBeNull();
|
||||
expect(dashboards.param('endTime')).toBeNull();
|
||||
});
|
||||
|
||||
test('TC-04 the global Zoom out button widens the range again', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
browserName,
|
||||
}) => {
|
||||
skipDragZoomOnWebkit(browserName);
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await target.chart.drag(0.35, 0.65);
|
||||
await expect.poll(() => dashboards.param('startTime')).not.toBeNull();
|
||||
|
||||
const zoomedStart = Number(dashboards.param('startTime'));
|
||||
|
||||
// No per-panel reset — zoom-out is global only.
|
||||
const zoomOut = dashboards.zoomOut();
|
||||
await expect(zoomOut).toBeVisible();
|
||||
await zoomOut.click();
|
||||
|
||||
await expect
|
||||
.poll(() => Number(dashboards.param('startTime')))
|
||||
.toBeLessThan(zoomedStart);
|
||||
});
|
||||
|
||||
test('TC-05 zooming inside the View modal leaves the dashboard window alone', async ({
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await panels.view.open(SINGLE_PANEL_ID);
|
||||
await expect(panels.view.chart.plotArea()).toBeVisible();
|
||||
|
||||
await panels.view.chart.drag(0.3, 0.7);
|
||||
|
||||
// The modal keeps its own window.
|
||||
expect(dashboards.param('startTime')).toBeNull();
|
||||
expect(dashboards.param('endTime')).toBeNull();
|
||||
});
|
||||
});
|
||||
64
tests/e2e/tests/dashboards/v2/editor/capabilities.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
metricsQuery,
|
||||
PanelKind,
|
||||
promqlQuery,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
textPanelWithQueryDashboard,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: how the editor explains a disabled kind, and the backend enforcing the
|
||||
// same panelKind × queryType matrix.
|
||||
|
||||
test.describe('Dashboards V2 — editor capabilities', () => {
|
||||
test('TC-01 a disabled option explains itself in a tooltip', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: metricsQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.openTypeSwitcher();
|
||||
await expect(editor.typeOption(PanelKind.List)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
await editor.typeOption(PanelKind.List).hover();
|
||||
|
||||
// The wording is the contract users read.
|
||||
await expect(
|
||||
panels.tooltip.text("List doesn't support metrics data"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 the backend rejects a combination the editor disables', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
// Enforced server-side too, so an invalid pairing can never be persisted.
|
||||
// Pins the layers together: if capabilities.ts and allowedQueryKinds
|
||||
// drift, either the switcher contract or this fails.
|
||||
await expect(
|
||||
dashboards.seed(
|
||||
singlePanelDashboard({
|
||||
kind: PanelKind.PieChart,
|
||||
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
|
||||
}),
|
||||
),
|
||||
// Quotes in the message are JSON-escaped, so match around them.
|
||||
).rejects.toThrow(/PromQLQuery.*not supported by panel kind.*PieChartPanel/);
|
||||
});
|
||||
|
||||
test('TC-03 the backend rejects a Text panel that carries a query', async ({
|
||||
dashboards,
|
||||
}) => {
|
||||
await expect(dashboards.seed(textPanelWithQueryDashboard())).rejects.toThrow(
|
||||
/renders without a query and must have queries: \[\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
180
tests/e2e/tests/dashboards/v2/editor/context-links.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
expect,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-config';
|
||||
|
||||
// Scope: the Context Links dialog and that a saved link reaches
|
||||
// `panel.spec.links` — where it surfaces as a `drilldown-context-link`
|
||||
// (covered in panels/57-drilldown).
|
||||
|
||||
const ONE_LINK = singlePanelDashboard({
|
||||
links: [{ name: 'Original', url: 'https://example.com/one' }],
|
||||
});
|
||||
|
||||
test.describe('Dashboards V2 — editor context links', () => {
|
||||
test('TC-01 the section is offered and opens an empty dialog', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
|
||||
await expect(links.dialog()).toBeVisible();
|
||||
await expect(links.label()).toHaveValue('');
|
||||
await expect(links.url()).toHaveValue('');
|
||||
});
|
||||
|
||||
test('TC-02 Save stays disabled until the link is valid', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
|
||||
// A link with no URL would be a dead menu entry.
|
||||
await expect(links.save()).toBeDisabled();
|
||||
await links.label().fill('Runbook');
|
||||
await expect(links.save()).toBeDisabled();
|
||||
|
||||
await links.url().fill('https://example.com/runbook');
|
||||
await expect(links.save()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-03 a saved link persists into panel.spec.links', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('Runbook');
|
||||
await links.url().fill('https://example.com/runbook');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
|
||||
await expect(links.item(0)).toBeVisible();
|
||||
await editor.save();
|
||||
|
||||
const saved = (await config.saved(id)).links;
|
||||
expect(saved).toHaveLength(1);
|
||||
// "Label" persists as `name` (Perses link model).
|
||||
expect(saved[0]).toMatchObject({
|
||||
name: 'Runbook',
|
||||
url: 'https://example.com/runbook',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-04 Cancel discards the dialog without adding a link', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('Throwaway');
|
||||
await links.url().fill('https://example.com');
|
||||
await links.blurFields();
|
||||
await links.cancel().click();
|
||||
|
||||
await expect(links.dialog()).toHaveCount(0);
|
||||
await expect(links.item(0)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-05 an existing link can be edited', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.edit(0).click();
|
||||
await links.label().fill('Renamed');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
await editor.save();
|
||||
|
||||
expect((await config.saved(id)).links[0]).toMatchObject({
|
||||
name: 'Renamed',
|
||||
});
|
||||
});
|
||||
|
||||
test('TC-06 a link can be removed', async ({ dashboards, editor, config }) => {
|
||||
const id = await dashboards.seedAndEdit(ONE_LINK, SINGLE_PANEL_ID);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await config.contextLinks.remove(0).click();
|
||||
await editor.save();
|
||||
|
||||
expect((await config.saved(id)).links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-07 URL parameters can be added to a link', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const links = config.contextLinks;
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.contextLinks);
|
||||
await links.addButton().click();
|
||||
await links.label().fill('With params');
|
||||
await links.url().fill('https://example.com/search');
|
||||
|
||||
await links.blurFields();
|
||||
await links.addParam().click();
|
||||
await links.paramKey(0).fill('service');
|
||||
await links.paramValue(0).fill('adservice');
|
||||
await links.blurFields();
|
||||
await links.save().click();
|
||||
await editor.save();
|
||||
|
||||
const saved = (await config.saved(id)).links;
|
||||
expect(saved).toHaveLength(1);
|
||||
// Params fold into the persisted URL.
|
||||
expect(new URL(saved[0].url).searchParams.get('service')).toBe('adservice');
|
||||
});
|
||||
|
||||
test('TC-08 the header quick-add opens the dialog from a collapsed section', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
// One click must expand AND add (pendingAction hop).
|
||||
await editor.section.collapse(Section.contextLinks);
|
||||
await config.contextLinks.headerAdd().click();
|
||||
|
||||
await expect(editor.section.toggle(Section.contextLinks)).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
await expect(config.contextLinks.dialog()).toBeVisible();
|
||||
});
|
||||
});
|
||||
246
tests/e2e/tests/dashboards/v2/editor/picker.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
compactDashboard,
|
||||
emptyDashboard,
|
||||
expect,
|
||||
PANEL_TYPE_TILES,
|
||||
PanelKind,
|
||||
PATH,
|
||||
singlePanelDashboard,
|
||||
SINGLE_PANEL_ID,
|
||||
test,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: creating a panel — the New panel drawer, the route it hands off to,
|
||||
// and the JSON-Patch a save emits.
|
||||
//
|
||||
// The subtlety: a tile click only selects; the confirm button carries the
|
||||
// target section, and the editor route carries it on as `layoutIndex`.
|
||||
|
||||
test.describe('Dashboards V2 — panel picker', () => {
|
||||
test('TC-01 with one section a tile click selects and Add panel creates', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seed(emptyDashboard());
|
||||
await dashboards.openEmpty(id);
|
||||
|
||||
await dashboards.emptyAddPanel().click();
|
||||
// No section to choose, so the confirm names none.
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add panel');
|
||||
|
||||
await newPanel.pick(PanelKind.Table);
|
||||
await expect(page).toHaveURL(PATH.onDashboard(id));
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
await newPanel.confirm();
|
||||
expect(dashboards.param('panelKind')).toBe(PanelKind.Table);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 with several sections the confirm names its target section', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
|
||||
// Every section is titled, so the toolbar trigger defaults to a new root.
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Dashboard (root)');
|
||||
await expect(newPanel.tile(PanelKind.TimeSeries)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
|
||||
await newPanel.choose(PanelKind.Number);
|
||||
expect(dashboards.param('panelKind')).toBe(PanelKind.Number);
|
||||
expect(dashboards.param('layoutIndex')).toBe('root');
|
||||
});
|
||||
|
||||
test('TC-03 the chosen section becomes the new panel layoutIndex', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.pick(PanelKind.Number);
|
||||
await newPanel.chooseSection('1');
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Tabular');
|
||||
await newPanel.confirm();
|
||||
|
||||
expect(dashboards.param('layoutIndex')).toBe('1');
|
||||
});
|
||||
|
||||
test('TC-04 creating from an empty section targets that section', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
const dashboard = compactDashboard();
|
||||
|
||||
dashboard.spec.layouts.push({
|
||||
kind: 'Grid',
|
||||
spec: { display: { title: 'Empty' }, items: [] },
|
||||
});
|
||||
await dashboards.seedAndOpen(dashboard);
|
||||
|
||||
await newPanel.sectionAddPanel().scrollIntoViewIfNeeded();
|
||||
await newPanel.sectionAddPanel().click();
|
||||
|
||||
await expect(newPanel.root()).toBeVisible();
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to Empty');
|
||||
await newPanel.choose(PanelKind.TimeSeries);
|
||||
expect(dashboards.param('layoutIndex')).toBe('2');
|
||||
});
|
||||
|
||||
test('TC-05 saving a new panel emits add-panel and add-layout-item ops', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const patches = editor.capturePatches();
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
// List: its seeded query passes validation (see TC-06).
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.title().fill('Created from the modal');
|
||||
await editor.save();
|
||||
|
||||
// Targeted adds for panel AND grid item — a replace would clobber edits.
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
const ops = patches[patches.length - 1];
|
||||
const panelAdd = ops.find((op) => /^\/spec\/panels\/[^/]+$/.test(op.path));
|
||||
expect(panelAdd?.op).toBe('add');
|
||||
expect(
|
||||
ops.some((op) => /^\/spec\/layouts\/\d+\/spec\/items\/-$/.test(op.path)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('TC-06 the created panel lands on the dashboard and persists', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
const before = await dashboards.get(id);
|
||||
const beforeCount = Object.keys(before.spec.panels).length;
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
// List is the one kind whose seeded query saves as-is (logs `count()`);
|
||||
// metrics kinds are rejected until a metric is chosen — see TC-07.
|
||||
await newPanel.choose(PanelKind.List);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await editor.title().fill('Fresh list panel');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(
|
||||
panels.titles().filter({ hasText: 'Fresh list panel' }),
|
||||
).toBeVisible();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(Object.keys(after.spec.panels)).toHaveLength(beforeCount + 1);
|
||||
});
|
||||
|
||||
test('TC-07 saving a metrics panel with no metric chosen is rejected', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(PanelKind.Number);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
// Metrics kinds seed an empty aggregation, so the editor must stay open and
|
||||
// surface the reason rather than dropping the panel.
|
||||
const { ok, body } = await editor.trySave();
|
||||
expect(ok).toBe(false);
|
||||
expect(body).toContain('metric name is required');
|
||||
|
||||
await expect(editor.root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 the editor route redirects when panelKind is missing', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
// No kind to seed, so the page bounces back.
|
||||
await dashboards.visit(PATH.editor(id, 'new'));
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(panels.panel(SINGLE_PANEL_ID).root()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-09 a new section from the picker is created on save', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.pick(PanelKind.List);
|
||||
await newPanel.newSection('Fresh section');
|
||||
await expect(newPanel.confirmButton()).toHaveText('Add to new section');
|
||||
await newPanel.confirm();
|
||||
expect(dashboards.param('newSection')).toBe('Fresh section');
|
||||
|
||||
await editor.save();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const section = after.spec.layouts.find(
|
||||
(layout) => layout.spec.display?.title === 'Fresh section',
|
||||
);
|
||||
expect(section?.spec.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('TC-10 search narrows the tiles and offers a way back', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await dashboards.headerAddPanel().click();
|
||||
|
||||
// Matches name, description and group label.
|
||||
await newPanel.search().fill('pie');
|
||||
await expect(newPanel.tiles()).toHaveCount(1);
|
||||
await expect(newPanel.tile(PanelKind.PieChart)).toBeVisible();
|
||||
|
||||
await newPanel.search().fill('no-such-panel');
|
||||
await expect(newPanel.tiles()).toHaveCount(0);
|
||||
await expect(newPanel.emptyState()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 a category chip filters to its group', async ({
|
||||
dashboards,
|
||||
newPanel,
|
||||
}) => {
|
||||
await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
await dashboards.headerAddPanel().click();
|
||||
|
||||
await newPanel.category('Raw records').click();
|
||||
await expect(newPanel.groups()).toHaveText(['Raw records']);
|
||||
await expect(newPanel.tiles()).toHaveCount(
|
||||
PANEL_TYPE_TILES.filter((t) => t.category === 'Raw records').length,
|
||||
);
|
||||
|
||||
await newPanel.category('All').click();
|
||||
await expect(newPanel.tiles()).toHaveCount(PANEL_TYPE_TILES.length);
|
||||
});
|
||||
});
|
||||
142
tests/e2e/tests/dashboards/v2/editor/query.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
expect,
|
||||
GOLDEN,
|
||||
logsCountQuery,
|
||||
PanelKind,
|
||||
PATH,
|
||||
QueryTab,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the query pane and its commit semantics.
|
||||
//
|
||||
// The rule worth pinning: editing the builder does NOT move the preview (only
|
||||
// Run, or a structural change, commits into the draft) — but Save serialises
|
||||
// the LIVE query anyway, so an unrun edit still persists. Contradictory-looking
|
||||
// and easy to "fix" into a regression.
|
||||
|
||||
test.describe('Dashboards V2 — panel editor query pane', () => {
|
||||
test('TC-01 Run Query issues a fresh query_range', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await editor.query.run();
|
||||
});
|
||||
|
||||
test('TC-02 the run keyboard shortcut works from inside the builder', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
// Bound with onKeyDownCapture, so it fires from inside inputs too.
|
||||
await editor.query.runShortcut();
|
||||
});
|
||||
|
||||
test('TC-03 switching query type re-renders the pane and marks the panel dirty', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
|
||||
await editor.query.tab(QueryTab.promql).click();
|
||||
await expect(editor.query.tab(QueryTab.promql)).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
// Structural change: auto-commits without Run.
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 choosing a metric makes a new panel savable', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
newPanel,
|
||||
}) => {
|
||||
// create → configure → save. A new metrics panel seeds an empty
|
||||
// aggregation and is rejected until a metric is picked (picker TC-07).
|
||||
const id = await dashboards.seedAndOpen(singlePanelDashboard());
|
||||
|
||||
await dashboards.headerAddPanel().click();
|
||||
await newPanel.choose(PanelKind.TimeSeries);
|
||||
await expect(editor.root()).toBeVisible();
|
||||
|
||||
await editor.query.selectMetric(GOLDEN.metrics.calls);
|
||||
await editor.title().fill('Configured then saved');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const saved = Object.values(after.spec.panels).find(
|
||||
(candidate) => candidate.spec.display.name === 'Configured then saved',
|
||||
);
|
||||
expect(saved).toBeDefined();
|
||||
expect(JSON.stringify(saved?.spec.queries)).toContain(GOLDEN.metrics.calls);
|
||||
});
|
||||
|
||||
test('TC-05 an unrun query edit is still persisted by Save', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// No Run: `buildSaveSpec` serialises the live query, so the edit must
|
||||
// survive — otherwise saving without running loses work.
|
||||
await editor.query.selectMetric(GOLDEN.metrics.latencyCount);
|
||||
await editor.save();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const after = await dashboards.get(id);
|
||||
return JSON.stringify(
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.queries,
|
||||
).includes(GOLDEN.metrics.latencyCount);
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-06 the in-editor query survives a reload', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await editor.query.selectMetric(GOLDEN.metrics.latencySum);
|
||||
await editor.query.run();
|
||||
|
||||
// No forceReset, so the URL query wins and survives a refresh.
|
||||
await dashboards.reload();
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.query.metricInput()).toHaveValue(
|
||||
GOLDEN.metrics.latencySum,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-07 a logs panel runs without needing a metric', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
// Logs aggregate by expression, so there's no metric to fill.
|
||||
await expect(editor.query.runButton()).toBeVisible();
|
||||
await editor.query.run();
|
||||
await editor.save();
|
||||
});
|
||||
});
|
||||
194
tests/e2e/tests/dashboards/v2/editor/shell.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
EditorText,
|
||||
expect,
|
||||
PATH,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: the editor shell — editing, dirty badge, save, discard guard, locking.
|
||||
//
|
||||
// Two counter-intuitive behaviours are pinned so a "cleanup" doesn't change
|
||||
// them: Save is NOT gated on dirty state (TC-03), and only the in-app close
|
||||
// button guards unsaved edits — there is no beforeunload blocker (TC-07).
|
||||
|
||||
test.describe('Dashboards V2 — panel editor shell', () => {
|
||||
test('TC-01 the editor opens on the panel with its saved title', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.heading()).toBeVisible();
|
||||
await expect(editor.title()).toHaveValue('Solo panel');
|
||||
await expect(editor.preview.root()).toBeVisible();
|
||||
await expect(editor.query.builder()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 editing the title marks the editor dirty', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
await editor.title().fill('Renamed panel');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Save stays enabled on a pristine panel', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
|
||||
// Gated on editability, not isDirty.
|
||||
await expect(editor.unsavedBadge()).toHaveCount(0);
|
||||
await expect(editor.saveButton()).toBeEnabled();
|
||||
});
|
||||
|
||||
test('TC-04 saving persists the title and returns to the dashboard', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
const patches = editor.capturePatches();
|
||||
|
||||
await editor.title().fill('Renamed via editor');
|
||||
await editor.description().fill('Edited in the E2E suite');
|
||||
await editor.save();
|
||||
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Renamed via editor',
|
||||
);
|
||||
|
||||
// A single targeted add at the panel's spec pointer.
|
||||
const ops = patches[patches.length - 1];
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].path).toBe(`/spec/panels/${SINGLE_PANEL_ID}/spec`);
|
||||
expect(ops[0].op).toBe('add');
|
||||
});
|
||||
|
||||
test('TC-05 closing a pristine editor leaves immediately', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.close();
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(editor.discardModal()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 closing a dirty editor asks before discarding', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.title().fill('Throwaway edit');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
|
||||
await editor.close({ expectDirty: true, keepEditing: true });
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.title()).toHaveValue('Throwaway edit');
|
||||
|
||||
await editor.close({ expectDirty: true });
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-07 unsaved edits hold a refresh, but an in-app route change drops them', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
expect(await editor.blocksUnload()).toBe(false);
|
||||
|
||||
await editor.title().fill('Never saved');
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
expect(await editor.blocksUnload()).toBe(true);
|
||||
|
||||
// Known behaviour, not endorsed: a refresh or tab close prompts, but there
|
||||
// is no router blocker. If one is added, update this test, don't delete it.
|
||||
await dashboards.visit(PATH.dashboard(id));
|
||||
await expect(editor.discardModal()).toHaveCount(0);
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
|
||||
'Solo panel',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-08 a locked dashboard disables Save and never PATCHes', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
await dashboards.setLocked(id, true);
|
||||
|
||||
const patches = editor.capturePatches();
|
||||
await dashboards.openEditor(id, SINGLE_PANEL_ID);
|
||||
|
||||
const save = editor.saveButton();
|
||||
await expect(save).toBeDisabled();
|
||||
|
||||
await editor.hoverSave();
|
||||
await expect(panels.tooltip.text(EditorText.lockedReason)).toBeVisible();
|
||||
|
||||
// The store short-circuits a locked patch before the network.
|
||||
expect(patches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-09 an unknown panel id redirects back to the dashboard', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seed(singlePanelDashboard());
|
||||
|
||||
await dashboards.visit(PATH.editor(id, 'does-not-exist'));
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
await expect(editor.root()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-10 Switch to View Mode hands off to the View modal', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
panels,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchToView().click();
|
||||
await page.waitForURL(PATH.onDashboardWithParams(id));
|
||||
await expect(panels.view.content()).toBeVisible();
|
||||
});
|
||||
});
|
||||
167
tests/e2e/tests/dashboards/v2/editor/type-switch.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
expect,
|
||||
logsCountQuery,
|
||||
PanelKind,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-editor';
|
||||
|
||||
// Scope: switching kind mid-edit — what the session cache restores, and which
|
||||
// config survives a first-visit transfer. Config only carries when the TARGET
|
||||
// kind declares that control (buildPluginSpec).
|
||||
|
||||
test.describe('Dashboards V2 — panel type switching', () => {
|
||||
test('TC-01 switching kind re-renders the preview', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 switching marks the editor dirty but does not persist', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.switchType(PanelKind.Number);
|
||||
|
||||
await expect(editor.unsavedBadge()).toBeVisible();
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.TimeSeries,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 switching back restores the original kind and its settings from the session cache', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
// Logs, because List rejects metrics; time_series-shaped, because Table
|
||||
// can't read `raw`.
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table, query: logsCountQuery() }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
await editor.setTile('decimals', '3');
|
||||
|
||||
// Not the default, so a freshly built Table would not have it.
|
||||
await editor.switchType(PanelKind.List);
|
||||
await expect(editor.preview.renderer(PanelKind.List)).toBeVisible();
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
await expect(editor.tileInput('decimals', '3')).toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-04 the session cache does not survive a reload', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(singlePanelDashboard(), SINGLE_PANEL_ID);
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
// The cache is a ref on the mounted editor, so a reload drops it. Reload
|
||||
// alone — a redundant goto afterwards crashed the page on WebKit.
|
||||
await dashboards.reload();
|
||||
await expect(editor.root()).toBeVisible();
|
||||
await expect(editor.preview.renderer(PanelKind.TimeSeries)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a saved switch persists the new kind', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
|
||||
PanelKind.BarChart,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 a panel-wide unit fans out into per-column units on Table', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ pluginSpec: { formatting: { unit: 'ms' } } }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchType(PanelKind.Table);
|
||||
await editor.save();
|
||||
|
||||
// Table has no panel-wide unit, so it fans out to columns — one-way.
|
||||
const after = await dashboards.get(id);
|
||||
const formatting =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.formatting;
|
||||
expect(formatting?.unit).toBeUndefined();
|
||||
expect(Object.values(formatting?.columnUnits ?? {})).toContain('ms');
|
||||
});
|
||||
|
||||
test('TC-07 custom legend colours are dropped on a first-visit switch', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({
|
||||
pluginSpec: {
|
||||
legend: { position: 'bottom', customColors: { adservice: '#ff0000' } },
|
||||
},
|
||||
}),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
// Keyed by series label, which the new kind may not reproduce, so dropped.
|
||||
// Round-trips as null rather than being omitted.
|
||||
const after = await dashboards.get(id);
|
||||
const { customColors } =
|
||||
after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.legend ?? {};
|
||||
expect(customColors ?? undefined).toBeUndefined();
|
||||
});
|
||||
|
||||
test('TC-08 axis bounds survive a switch between kinds that both declare them', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard(),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.axes);
|
||||
await editor.control('soft-min').fill('5');
|
||||
await editor.control('soft-max').fill('50');
|
||||
|
||||
// Bar Chart also declares minMax, so the bounds must carry over.
|
||||
await editor.switchType(PanelKind.BarChart);
|
||||
await editor.save();
|
||||
|
||||
const after = await dashboards.get(id);
|
||||
const axes = after.spec.panels[SINGLE_PANEL_ID].spec.plugin.spec.axes;
|
||||
expect(axes?.softMin).toBe(5);
|
||||
expect(axes?.softMax).toBe(50);
|
||||
});
|
||||
});
|
||||
239
tests/e2e/tests/dashboards/v2/kinds/table.spec.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
COMPACT_PANELS,
|
||||
compactDashboard,
|
||||
expect,
|
||||
PanelKind,
|
||||
PATH,
|
||||
QueryRange,
|
||||
Section,
|
||||
SINGLE_PANEL_ID,
|
||||
singlePanelDashboard,
|
||||
test,
|
||||
} from '../../../../drivers/panel-config';
|
||||
|
||||
// Table has no panel-wide `unit`; it carries `columnUnits` per column.
|
||||
|
||||
test.describe('Dashboards V2 — Table panel', () => {
|
||||
test.describe('rendering', () => {
|
||||
test('TC-01 header search filters the table and can be cleared', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [
|
||||
['adservice', 10],
|
||||
['cartservice', 20],
|
||||
['frontend', 30],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const table = panels.panel(COMPACT_PANELS.table);
|
||||
await table.waitRendered(PanelKind.Table);
|
||||
|
||||
const rows = table.table.rows();
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
await table.search.open('cart');
|
||||
await expect(rows).toHaveCount(1);
|
||||
|
||||
await table.search.clear().click();
|
||||
await expect(rows).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('TC-02 Escape closes the search box', async ({ dashboards, panels }) => {
|
||||
await dashboards.seedAndOpen(compactDashboard());
|
||||
|
||||
const table = panels.panel(COMPACT_PANELS.table);
|
||||
await table.scrollIntoView();
|
||||
await table.search.open('cart');
|
||||
|
||||
await panels.pressEscape();
|
||||
await expect(table.search.input()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 a resized column persists across a reload', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
panels,
|
||||
}) => {
|
||||
await queryRange.respond(
|
||||
QueryRange.scalar({
|
||||
groupColumns: ['service.name'],
|
||||
aggregationColumns: ['A'],
|
||||
rows: [['adservice', 10]],
|
||||
}),
|
||||
);
|
||||
await dashboards.seedAndOpen(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
);
|
||||
|
||||
const target = panels.panel(SINGLE_PANEL_ID);
|
||||
await expect(target.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
const before = await target.table.headerWidth('service.name');
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
await target.table.resizeColumn('service.name', 120);
|
||||
|
||||
await expect
|
||||
.poll(() => target.table.headerWidth('service.name'))
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
// Widths persist behind a 400ms debounce; reloading before it flushes drops
|
||||
// the write and looks exactly like a persistence bug.
|
||||
await expect
|
||||
.poll(() => target.table.storedWidth('service.name'))
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
const widened = await target.table.headerWidth('service.name');
|
||||
await dashboards.reload();
|
||||
await expect(target.renderer(PanelKind.Table)).toBeVisible();
|
||||
await expect
|
||||
.poll(() => target.table.headerWidth('service.name'))
|
||||
.toBeCloseTo(widened, -1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('column units', () => {
|
||||
test('TC-04 Table offers per-column units instead of a panel-wide unit', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
// Golden data: the column key is derived from the resolved result
|
||||
// (`column.id || column.name`), which a hand-rolled payload must match
|
||||
// exactly or the column renders unnamed.
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
// Table declares `columnUnits`, never `unit`.
|
||||
await expect(editor.control('unit')).toHaveCount(0);
|
||||
await expect(config.columnUnits.any()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 the column-units editor explains itself before the panel has run', async ({
|
||||
dashboards,
|
||||
queryRange,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
// No result means no columns, so the section shows a hint.
|
||||
await queryRange.respond(QueryRange.empty());
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await editor.section.expand(Section.formatting);
|
||||
await expect(
|
||||
config.text('Run the query to set a unit for each value column.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 a per-column unit persists', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
await editor.section.expand(Section.formatting);
|
||||
|
||||
// Read the key off the control rather than assuming the derivation.
|
||||
await expect(config.columnUnits.any()).toBeVisible();
|
||||
const column = await config.columnUnits.first();
|
||||
expect(column.key).not.toBe('');
|
||||
|
||||
await editor.searchSelect(column.testId, 'Milliseconds', 'Milliseconds');
|
||||
await editor.save();
|
||||
|
||||
const { formatting } = (await config.saved(id)).plugin;
|
||||
expect(formatting?.columnUnits?.[column.key]).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('table thresholds', () => {
|
||||
test('TC-07 a table threshold paints the targeted cell background', async ({
|
||||
authedPage: page,
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
// The column key is derived from the live result, so pick it from the
|
||||
// dropdown; ">= 0" fires on any non-negative value.
|
||||
const id = await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
const valueCell = config.table.valueCell();
|
||||
await expect(valueCell).toBeVisible();
|
||||
expect(await config.table.background(valueCell)).toBe('');
|
||||
|
||||
await editor.section.expand(Section.thresholds);
|
||||
await config.thresholds.addButton('table').click();
|
||||
const row = config.thresholds.row('table', 0);
|
||||
await row.pickFirstColumn();
|
||||
await row.value().fill('0');
|
||||
await row.setOperator('above_or_equal');
|
||||
await row.setFormat('background');
|
||||
await row.save().click();
|
||||
|
||||
// Live in the preview, before saving.
|
||||
await expect.poll(() => config.table.background(valueCell)).not.toBe('');
|
||||
|
||||
await editor.save();
|
||||
|
||||
// And on the saved panel back on the dashboard.
|
||||
await page.waitForURL(PATH.onDashboard(id));
|
||||
const savedCell = config.table.valueCell();
|
||||
await expect(savedCell).toBeVisible();
|
||||
await expect.poll(() => config.table.background(savedCell)).not.toBe('');
|
||||
});
|
||||
|
||||
test('TC-08 the text format colours the value, not the cell', async ({
|
||||
dashboards,
|
||||
editor,
|
||||
config,
|
||||
}) => {
|
||||
await dashboards.seedAndEdit(
|
||||
singlePanelDashboard({ kind: PanelKind.Table }),
|
||||
SINGLE_PANEL_ID,
|
||||
);
|
||||
await expect(editor.preview.renderer(PanelKind.Table)).toBeVisible();
|
||||
|
||||
const valueCell = config.table.valueCell();
|
||||
await expect(valueCell).toBeVisible();
|
||||
|
||||
await editor.section.expand(Section.thresholds);
|
||||
await config.thresholds.addButton('table').click();
|
||||
const row = config.thresholds.row('table', 0);
|
||||
await row.pickFirstColumn();
|
||||
await row.value().fill('0');
|
||||
await row.setOperator('above_or_equal');
|
||||
await row.setFormat('text');
|
||||
await row.save().click();
|
||||
|
||||
// Text recolours the value; background paints the cell. Not interchangeable.
|
||||
await expect
|
||||
.poll(() => config.table.coloredText(valueCell).count())
|
||||
.toBeGreaterThan(0);
|
||||
expect(await config.table.background(valueCell)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import RequestType, build_raw_query, get_rows, make_query_request
|
||||
|
||||
SERVICE = "scalar-object-body"
|
||||
|
||||
|
||||
def test_scalar_and_object_body_key_collapses(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
export_json_types: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""A log body key stored as both a scalar and an object (db.function and db.function.arg) keeps
|
||||
only one: the body is returned nested, not flattened to dotted keys, so it cannot hold both."""
|
||||
now = datetime.now(tz=UTC)
|
||||
logs = [
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
resources={"service.name": SERVICE},
|
||||
body_v2=json.dumps({"db.function": "refresh", "db.function.arg": 2}),
|
||||
body_promoted="",
|
||||
),
|
||||
]
|
||||
export_json_types(logs)
|
||||
insert_logs(logs)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[build_raw_query("A", "logs", limit=10, filter_expression=f"service.name = '{SERVICE}'")],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = get_rows(response)
|
||||
assert len(rows) == 1
|
||||
body = rows[0]["data"]["body"]
|
||||
# The scalar survives as a string; the sibling object path db.function.arg is not preserved
|
||||
# under it, so db.function is a plain value rather than an object.
|
||||
assert body["db"]["function"] == "refresh"
|
||||
assert isinstance(body["db"]["function"], str)
|
||||
@@ -1,74 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.querier import (
|
||||
BuilderQuery,
|
||||
OrderBy,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces
|
||||
|
||||
|
||||
def test_traces_scalar_and_object_key_both_survive(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A span attribute stored as both a scalar and an object prefix (db.function and
|
||||
db.function.arg) keeps both dotted keys through the native JSON read. json_only writes only the
|
||||
JSON column, so no legacy attribute map backfills the keys."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", evolution_time)
|
||||
|
||||
service = "scalar-object-service"
|
||||
span_time = evolution_time + timedelta(minutes=5)
|
||||
insert_traces(
|
||||
[
|
||||
Traces(
|
||||
timestamp=span_time,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="scalar and object",
|
||||
resources={"service.name": service},
|
||||
attributes={"db.function": "refresh", "db.function.arg": 2},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((span_time - timedelta(minutes=1)).timestamp() * 1000),
|
||||
end_ms=int((span_time + timedelta(minutes=1)).timestamp() * 1000),
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
name="A",
|
||||
limit=10,
|
||||
filter_expression=f"resource.service.name = '{service}'",
|
||||
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
rows = get_rows(response)
|
||||
assert len(rows) == 1
|
||||
attributes = rows[0]["data"]["attributes"]
|
||||
assert attributes["db.function"] == "refresh"
|
||||
assert isinstance(attributes["db.function"], str)
|
||||
assert attributes["db.function.arg"] == 2
|
||||
# the native read returns flat dotted keys, so no Go-side flattening is needed: db is not nested
|
||||
assert "db" not in attributes
|
||||