Compare commits

..

53 Commits

Author SHA1 Message Date
Abhi Kumar
4cf28f572b refactor(dashboard): rename scatter click helper and type legend series mocks 2026-10-22 13:49:38 +05:30
Abhi Kumar
db3fc8bcc3 chore: pr review changes 2026-10-22 01:49:17 +05:30
Abhi Kumar
04810eb479 fix(dashboard): drop PromQL from scatter plot query types 2026-10-22 01:30:06 +05:30
Abhi Kumar
a5a8710de5 fix(dashboard): declare scatter plot query modes 2026-10-22 01:30:06 +05:30
Abhi Kumar
859e61615a refactor(dashboard): trim scatter plot panel comments and share the joined table helper 2026-10-22 01:30:06 +05:30
Abhi Kumar
10eace166f fix(dashboard): warn of the scatter plot cap only when groups were capped 2026-10-22 01:30:06 +05:30
Abhi Kumar
afec3394fa feat(dashboard): let scatter plot thresholds mark the x or y axis 2026-10-22 01:30:06 +05:30
Abhi Kumar
d0d080e256 feat(dashboard): use colorBy and sizeBy in the scatter plot, labelled Color by and Size by 2026-10-22 01:29:32 +05:30
Abhi Kumar
bb78d9986d feat(dashboard): explain mismatched group bys in the scatter plot 2026-10-22 01:29:32 +05:30
Abhi Kumar
ac96f5a44b refactor(dashboard): move scatter plot panel helpers into utils 2026-10-22 01:29:32 +05:30
Abhi Kumar
9823e5f86f style(charts): match scatter tooltip spacing and marker to the heatmap tooltip and legend 2026-10-22 01:29:32 +05:30
Abhi Kumar
eb905f7b8b fix(charts): drop the scatter tooltip size row when it repeats x or y 2026-10-22 01:29:22 +05:30
Abhi Kumar
6d3d1a2ced feat(dashboard): sync scatter plot group by across queries
Panel kinds declare which builder fields to keep equal across queries. A query
added or switched to another signal takes the others' values, since a signal
switch reloads its own.
2026-10-22 01:29:22 +05:30
Abhi Kumar
702c39873a feat(dashboard): warn when a scatter plot dimension isn't in the results 2026-10-22 01:29:22 +05:30
Abhi Kumar
a9e67a1839 feat(query-builder): show the scatter plot's default limit as the limit placeholder 2026-10-22 01:29:22 +05:30
Abhi Kumar
66dbc69d12 feat(dashboard): move scatter plot notes from the footer into the panel warning 2026-10-22 01:29:22 +05:30
Abhi Kumar
35f4f228fb fix(dashboard): limit scatter plot queries to 10,000 groups on the server
Only a lone builder query gets the default: per-query limits on several
queries can keep different groups and break the join.
2026-10-22 01:29:22 +05:30
Abhi Kumar
bf4eaf6b9c fix(dashboard): cap the scatter plot at 10,000 dots
A canvas path of a few hundred thousand dots fails to paint and takes the later series with it.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
ddb9a1a16c fix(dashboard): redraw the scatter plot after an empty state
The chart area stays mounted, since the size observer binds to its node only once.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
d5ffbc25b7 refactor(dashboard): build scatter plot axes from the shared axis fields
Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
46726465bf feat(dashboard): lay out scatter plot axes with label, range and scale rows
Range help says when a log axis ignores a bound at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
b6c970b645 feat(dashboard): restyle scatter plot dimensions with field info and auto labels
Colour is a multi-select of group-by keys; Auto options name the column they resolve to.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
ee7b60bf69 feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
0075ef5d66 fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
8db3efc257 feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
521fdedccd feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
6473cd7eae fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
24098249ab fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
982b96b922 feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
e6d0260987 feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
51289501d2 chore: pr review changes 2026-10-22 01:28:03 +05:30
Abhi Kumar
c92b7e7048 style(charts): format scatter chart data with oxfmt 0.73 2026-10-22 00:51:44 +05:30
Abhi Kumar
b65be666a9 refactor(charts): trim scatter chart layer comments and dead code 2026-10-22 00:45:35 +05:30
Abhi Kumar
d5d00a8e3b fix(charts): pick the scatter auto scale by how the points spread
Max/min decades let one outlier decide and never left linear with zeros.
Compare middle-half coverage instead, falling back to symlog.
2026-10-22 00:45:35 +05:30
Abhi Kumar
924876a5d6 fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
5ab8f6e856 fix(dashboard): honour scatter plot soft limits on every axis scale
Only Auto weighs the soft limits when choosing a scale; an explicit scale keeps its choice.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
7b22b9718a feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
d47d7315e8 fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
bf39698b75 fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
35874d28bf feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
08173e5223 fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
8601ebf8ea fix(charts): outline the hovered scatter point
Snap the ring to whole pixels so uPlot's ceil on left/top doesn't shift it
off the disc.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
711d224efe feat(charts): scatter axis scales and titles
auto picks log only for positive values spanning three decades; log falls
back to symlog when given values at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
3ae527eddb chore: scatter plot base work 2026-10-22 00:45:35 +05:30
Abhi Kumar
75cf276320 feat(dashboards): add an axis to scatter plot thresholds 2026-10-22 00:43:26 +05:30
Naman Verma
ed489f9ec0 chore: add more validations for scatter plot 2026-10-22 00:43:26 +05:30
Abhi Kumar
afea1cabf6 feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-22 00:43:26 +05:30
Abhi Kumar
1bbdcfb517 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
f823217030 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
d1a7f4a254 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
dca6628c94 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
f754129103 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
781294fb27 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
468 changed files with 16350 additions and 8491 deletions

View File

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

View File

@@ -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,60 @@ 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.
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).
- 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

View File

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

View File

@@ -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,27 +31,44 @@ 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(
'http://localhost/api/v2/saved_views',
'http://localhost/api/v1/explorer/views',
response.json((req) =>
savedViews(values.savedViews, req.url.searchParams.get('source') ?? 'logs'),
savedViews(values.savedViews, req.url.searchParams.get('sourcePage') ?? 'logs'),
),
),
```
@@ -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.

View File

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

View File

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

View File

@@ -3783,6 +3783,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4525,6 +4532,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4540,6 +4548,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4553,6 +4562,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4638,6 +4648,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4717,6 +4739,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4905,6 +4937,104 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5018,6 +5148,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text

View File

@@ -140,7 +140,7 @@ const preview: Preview = {
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',
['Explorer', 'Live Tail', 'Pipelines', 'Settings'],
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
'Metrics',

View File

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

View File

@@ -0,0 +1,3 @@
{
"name_of_the_view": "Name of the view"
}

View File

@@ -47,6 +47,7 @@
"INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring",
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",

View File

@@ -12,7 +12,6 @@
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
@@ -187,32 +186,8 @@
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
"button_discard_channel": "Discard",
"button_discard_confirm": "Discard",
"discard_changes_title": "Discard your changes?",
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
"tooltip_save_no_changes": "Nothing has changed yet.",
"entry_slack_field": "Field",
"entry_slack_action": "Action",
"entry_incidentio_metadata": "Entry",
"label_slack_field_title": "Title",
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
"label_slack_field_value": "Value",
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
"label_incidentio_metadata_key": "Key",
"label_incidentio_metadata_value": "Value",
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
"tooltip_slack_action_text": "The label on the button.",
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory",
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",

View File

@@ -0,0 +1,4 @@
{
"name_of_the_view": "Name of the view",
"delete_confirm_message": "Are you sure you want to delete {{viewName}} view? Deleting a view is irreversible and cannot be undone."
}

View File

@@ -50,6 +50,8 @@
"WORKSPACE_SUSPENDED": "SigNoz | Workspace Suspended",
"WORKSPACE_ACCESS_RESTRICTED": "SigNoz | Workspace Access Restricted",
"SUPPORT": "SigNoz | Support",
"LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views",
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",
"TRACES_FUNNELS": "SigNoz | Traces Funnels",
"DEFAULT": "Open source Observability Platform | SigNoz",
"SHORTCUTS": "SigNoz | Shortcuts",
@@ -64,8 +66,10 @@
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
"API_MONITORING": "SigNoz | External APIs",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",

View File

@@ -303,10 +303,10 @@ describe('PrivateRoute', () => {
it.each([
['/logs-save-views', ROUTES.LOGS_EXPLORER],
['/traces-save-views', ROUTES.TRACES_EXPLORER],
['/logs/saved-views', ROUTES.LOGS_EXPLORER],
['/traces/saved-views', ROUTES.TRACES_EXPLORER],
['/metrics-explorer/views', ROUTES.METRICS_EXPLORER_EXPLORER],
['/meter/explorer/views', ROUTES.METER_EXPLORER],
[ROUTES.LOGS_SAVE_VIEWS, ROUTES.LOGS_EXPLORER],
[ROUTES.TRACES_SAVE_VIEWS, ROUTES.TRACES_EXPLORER],
[ROUTES.METRICS_EXPLORER_VIEWS, ROUTES.METRICS_EXPLORER_EXPLORER],
[ROUTES.METER_EXPLORER_VIEWS, ROUTES.METER_EXPLORER],
])('should redirect the old views route %s to %s', (from, to) => {
renderPrivateRoute({ initialRoute: from });

View File

@@ -18,6 +18,7 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -65,6 +66,7 @@ function App(): JSX.Element {
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isSavedViewEnabled = useSavedViewEnabled();
const chatSupport = useChatSupport();
const { hostname } = window.location;
@@ -254,8 +256,18 @@ function App(): JSX.Element {
useEffect(() => {
// The bottom strip carries Support, so the floating bubble goes entirely.
window.Pylon?.('hideChatBubble');
}, [pathname]);
if (
isSavedViewEnabled ||
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname === '/ai-assistant' ||
pathname.startsWith('/ai-assistant/')
) {
window.Pylon?.('hideChatBubble');
} else {
window.Pylon?.('showChatBubble');
}
}, [pathname, isSavedViewEnabled]);
// Identity for the Pylon widget. Whether this user gets Pylon at all is
// `useChatSupport`'s call — this only fills in who they are.

View File

@@ -26,6 +26,10 @@ export const ServiceMapPage = Loadable(
() => import(/* webpackChunkName: "ServiceMapPage" */ 'modules/Servicemap'),
);
export const LogsSaveViews = Loadable(
() => import(/* webpackChunkName: "LogsSaveViews" */ 'pages/LogsModulePage'), // TODO: Add a wrapper so that the same component can be used in traces
);
export const TracesExplorer = Loadable(
() =>
import(
@@ -33,6 +37,11 @@ export const TracesExplorer = Loadable(
),
);
export const TracesSaveViews = Loadable(
() =>
import(/* webpackChunkName: "Traces Save Views" */ 'pages/TracesModulePage'),
);
export const TracesFunnels = Loadable(
() =>
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),

View File

@@ -26,6 +26,7 @@ import {
Login,
Logs,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
@@ -47,6 +48,7 @@ import {
TracesExplorer,
TracesFunnelDetails,
TracesFunnels,
TracesSaveViews,
UnAuthorized,
UsageExplorerPage,
WorkspaceAccessRestricted,
@@ -118,6 +120,13 @@ const routes: AppRoutes[] = [
exact: true,
key: 'SERVICE_MAP',
},
{
path: ROUTES.LOGS_SAVE_VIEWS,
component: LogsSaveViews,
isPrivate: true,
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
{
path: ROUTES.TRACE_DETAIL,
exact: true,
@@ -209,6 +218,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'TRACES_EXPLORER',
},
{
path: ROUTES.TRACES_SAVE_VIEWS,
exact: true,
component: TracesSaveViews,
isPrivate: true,
key: 'TRACES_SAVE_VIEWS',
},
{
path: ROUTES.TRACES_FUNNELS,
exact: true,
@@ -398,6 +414,13 @@ const routes: AppRoutes[] = [
key: 'METRICS_EXPLORER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VIEWS,
exact: true,
component: MetricsExplorer,
key: 'METRICS_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
exact: true,
@@ -420,6 +443,13 @@ const routes: AppRoutes[] = [
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER_VIEWS,
exact: true,
component: MeterExplorerPage,
key: 'METER_EXPLORER_VIEWS',
isPrivate: true,
},
{
path: ROUTES.API_MONITORING,
exact: true,

View File

@@ -5062,6 +5062,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5818,6 +5824,126 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5828,7 +5954,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6810,6 +6937,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -0,0 +1,5 @@
import axios from 'api';
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
export const deleteView = (uuid: string): Promise<DeleteViewPayloadProps> =>
axios.delete(`/explorer/views/${uuid}`);

View File

@@ -0,0 +1,9 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { AllViewsProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const getAllViews = (
sourcepage: DataSource | 'meter',
): Promise<AxiosResponse<AllViewsProps>> =>
axios.get(`/explorer/views?sourcePage=${sourcepage}`);

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { ViewProps } from 'types/api/saveViews/types';
/**
* Fetches a single saved view by ID (`GET /api/v1/explorer/views/{viewId}`).
*
* Hand-maintained alongside the other `api/saveView/*` clients — explorer views
* are not in `docs/api/openapi.yml`, so Orval does not generate a hook here
* (unlike e.g. `useGetChannelByID` under `api/generated/services/channels`).
*
* Used by the AI assistant "Open view" action to load `compositeQuery` and
* navigate to the correct explorer without listing every view per source page.
* See `container/AIAssistant/components/ActionsSection/utils/openSavedView.ts`.
*/
export interface GetViewByIdProps {
status: string;
data: ViewProps;
}
export const getViewById = (
viewKey: string,
): Promise<AxiosResponse<GetViewByIdProps>> =>
axios.get(`/explorer/views/${viewKey}`);

View File

@@ -0,0 +1,16 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
export const saveView = ({
compositeQuery,
sourcePage,
viewName,
extraData,
}: SaveViewProps): Promise<AxiosResponse<SaveViewPayloadProps>> =>
axios.post('/explorer/views', {
name: viewName,
sourcePage,
compositeQuery,
extraData,
});

View File

@@ -0,0 +1,19 @@
import axios from 'api';
import {
UpdateViewPayloadProps,
UpdateViewProps,
} from 'types/api/saveViews/types';
export const updateView = ({
compositeQuery,
viewName,
extraData,
sourcePage,
viewKey,
}: UpdateViewProps): Promise<UpdateViewPayloadProps> =>
axios.put(`/explorer/views/${viewKey}`, {
name: viewName,
compositeQuery,
extraData,
sourcePage,
});

View File

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

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -274,6 +275,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -15,12 +15,12 @@
.title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
max-width: 400px;
}

View File

@@ -9,18 +9,18 @@
}
.icon {
color: var(--l2-foreground);
color: var(--text-vanilla-400);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
max-width: 400px;
}

View File

@@ -1,24 +0,0 @@
/* The space sits between the way back and the title, rather than above both of
them, so the two read as separate steps. */
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-height: 26px;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--l1-foreground);
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
}

View File

@@ -1,56 +0,0 @@
import { ReactNode } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BackHeader.module.scss';
export interface BackHeaderProps {
/** Where the page came from, named as the place rather than the action. */
backLabel: string;
backRoute: string;
title: ReactNode;
testId?: string;
}
/**
* A way back on its own line, then the page's own title. The two rows keep the
* title clear of whatever sits beside it, such as a copy button.
*/
function BackHeader({
backLabel,
backRoute,
title,
testId,
}: BackHeaderProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
return (
<>
<div className={styles.header} data-testid={testId}>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<ArrowLeft size={12} />}
onClick={(e: React.MouseEvent): void => {
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
}}
testId="back-link"
>
{backLabel}
</Button>
<div className={styles.title}>{title}</div>
</div>
<Divider className={styles.divider} />
</>
);
}
BackHeader.defaultProps = {
testId: undefined,
};
export default BackHeader;

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from 'antd';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import logEvent from 'api/common/logEvent';
import { MessageSquareText } from '@signozhq/icons';
export default function ChatSupportGateway(): JSX.Element {
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
useState(false);
const { pathname } = useLocation();
return (
<>
<div className="chat-support-gateway">
<Button
className="chat-support-gateway-btn"
onClick={(): void => {
logEvent('Disabled Chat Support: Clicked', {
source: `chat support icon`,
page: pathname,
});
setIsAddCreditCardModalOpen(true);
}}
>
<MessageSquareText size={24} />
</Button>
</div>
<AddCreditCardModal
open={isAddCreditCardModalOpen}
onClose={(): void => setIsAddCreditCardModalOpen(false)}
onAddCreditCard={(): void => {
logEvent('Add Credit card modal: Clicked', {
source: `chat support icon`,
page: pathname,
});
}}
/>
</>
);
}

View File

@@ -0,0 +1,252 @@
import { useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { useUpdateView } from 'hooks/saveViews/useUpdateView';
import useErrorNotification from 'hooks/useErrorNotification';
import { useNotifications } from 'hooks/useNotifications';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { popupContainer } from 'utils/selectPopupContainer';
import { ExploreHeaderToolTip, SaveButtonText } from './constants';
import MenuItemGenerator from './MenuItemGenerator';
import SaveViewWithName from './SaveViewWithName';
import {
DropDownOverlay,
ExplorerCardHeadContainer,
OffSetCol,
} from './styles';
import { ExplorerCardProps } from './types';
import { deleteViewHandler } from './utils';
import { Ellipsis, Save, Share2, Trash2 } from '@signozhq/icons';
function ExplorerCard({
sourcepage,
children,
}: ExplorerCardProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [, setCopyUrl] = useCopyToClipboard();
const { notifications } = useNotifications();
const onCopyUrlHandler = (): void => {
setCopyUrl(window.location.href);
notifications.success({
message: 'Copied to clipboard',
});
};
const {
currentQuery,
panelType,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
isStagedQueryUpdated,
} = useQueryBuilder();
const {
data: viewsData,
isLoading,
error,
isRefetching,
refetch: refetchAllView,
} = useGetAllViews(sourcepage);
useErrorNotification(error);
const handleOpenChange = (newOpen = false): void => {
setIsOpen(newOpen);
};
const { viewName, viewKey } = useGetSavedViewParams();
const { options } = useOptionsMenu({
storageKey:
sourcepage === DataSource.TRACES
? LOCALSTORAGE.TRACES_LIST_OPTIONS
: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: sourcepage,
aggregateOperator: StringOperators.NOOP,
});
const isQueryUpdated = isStagedQueryUpdated(
viewsData?.data?.data,
viewKey,
options,
);
const { mutateAsync: updateViewAsync } = useUpdateView({
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
viewKey,
extraData: '',
sourcePage: sourcepage,
viewName,
});
const { mutateAsync: deleteViewAsync } = useDeleteView(viewKey);
const showErrorNotification = (err: Error): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
};
const onDeleteHandler = (): void =>
deleteViewHandler({
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewId: viewKey,
viewKey,
updateAllQueriesOperators,
sourcePage: sourcepage,
});
const onUpdateQueryHandler = (): void => {
updateViewAsync(
{
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
viewKey,
extraData: '',
sourcePage: sourcepage,
viewName,
},
{
onSuccess: () => {
notifications.success({
message: 'View Updated Successfully',
});
refetchAllView();
},
onError: (err) => {
showErrorNotification(err);
},
},
);
};
const moreOptionItems: DropdownItemType[] = [
{
type: 'item',
value: 'delete',
label: <Typography.Text strong>Delete</Typography.Text>,
onClick: onDeleteHandler,
prefix: <Trash2 size="md" />,
},
];
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
const saveButtonIcon = isQueryUpdated ? null : <Save size="md" />;
const showSaveView = false;
return (
<>
{showSaveView && (
<ExplorerCardHeadContainer size="small">
<Row align="middle">
<Col span={6}>
<Space>
<Typography>Query Builder</Typography>
<TextToolTip
url={ExploreHeaderToolTip.url}
text={ExploreHeaderToolTip.text}
useFilledIcon={false}
/>
</Space>
</Col>
<OffSetCol span={18}>
<Space size="large">
{viewsData?.data.data && viewsData?.data.data.length && (
<Space>
<Select
getPopupContainer={popupContainer}
loading={isLoading || isRefetching}
showSearch
placeholder="Select a view"
dropdownStyle={DropDownOverlay}
dropdownMatchSelectWidth={false}
optionLabelProp="value"
value={viewName || undefined}
>
{viewsData?.data.data.map((view) => (
<Select.Option key={view.id} value={view.name}>
<MenuItemGenerator
viewName={view.name}
viewKey={viewKey}
createdBy={view.createdBy}
uuid={view.id}
refetchAllView={refetchAllView}
viewData={viewsData.data.data}
sourcePage={sourcepage}
/>
</Select.Option>
))}
</Select>
</Space>
)}
{isQueryUpdated && (
<Button type="primary" icon={<Save />} onClick={onUpdateQueryHandler}>
Save changes
</Button>
)}
<Popover
getPopupContainer={popupContainer}
placement="bottomLeft"
trigger="click"
content={
<SaveViewWithName
sourcePage={sourcepage}
handlePopOverClose={handleOpenChange}
refetchAllView={refetchAllView}
/>
}
showArrow={false}
open={isOpen}
onOpenChange={handleOpenChange}
>
<Button
type={saveButtonType}
icon={saveButtonIcon}
data-testid="traces-save-view-action"
>
{isQueryUpdated
? SaveButtonText.SAVE_AS_NEW_VIEW
: SaveButtonText.SAVE_VIEW}
</Button>
</Popover>
<Share2 onClick={onCopyUrlHandler} size="md" />
{viewKey && (
<Dropdown
items={moreOptionItems}
nativeButton
align="end"
side="bottom"
>
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
</Dropdown>
)}
</Space>
</OffSetCol>
</Row>
</ExplorerCardHeadContainer>
)}
<div>{children}</div>
</>
);
}
export default ExplorerCard;

View File

@@ -0,0 +1,103 @@
import { MouseEvent, useCallback } from 'react';
import { Trash2 } from '@signozhq/icons';
import { Col, Row, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDeleteView } from 'hooks/saveViews/useDeleteView';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { MenuItemContainer } from './styles';
import { MenuItemLabelGeneratorProps } from './types';
import {
deleteViewHandler,
getViewDetailsUsingViewKey,
trimViewName,
} from './utils';
function MenuItemGenerator({
viewName,
viewKey,
createdBy,
uuid,
viewData,
refetchAllView,
sourcePage,
}: MenuItemLabelGeneratorProps): JSX.Element {
const { panelType, redirectWithQueryBuilderData, updateAllQueriesOperators } =
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { notifications } = useNotifications();
const { mutateAsync: deleteViewAsync } = useDeleteView(uuid);
const onDeleteHandler = (event: MouseEvent<SVGSVGElement>): void => {
event.stopPropagation();
deleteViewHandler({
deleteViewAsync,
notifications,
panelType,
redirectWithQueryBuilderData,
refetchAllView,
viewId: uuid,
viewKey,
updateAllQueriesOperators,
sourcePage,
});
};
const onMenuItemSelectHandler = useCallback(
({ key }: { key: string }): void => {
const currentViewDetails = getViewDetailsUsingViewKey(key, viewData);
if (!currentViewDetails) {
return;
}
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
handleExplorerTabChange(currentPanelType, {
query,
viewName: name,
viewKey: id,
});
},
[viewData, handleExplorerTabChange],
);
const onLabelClickHandler = (): void => {
onMenuItemSelectHandler({
key: uuid,
});
};
const newViewName = trimViewName(viewName);
return (
<MenuItemContainer onClick={onLabelClickHandler}>
<Row justify="space-between">
<Col span={22}>
<Row>
<Tooltip title={viewName}>
<Typography.Text strong>{newViewName}</Typography.Text>
</Tooltip>
</Row>
<Row>
<Typography.Text color="muted">Created by {createdBy}</Typography.Text>
</Row>
</Col>
<Col span={2}>
<Typography.Link>
<Trash2
role="img"
aria-label="Delete view"
onClick={onDeleteHandler}
size="md"
/>
</Typography.Link>
</Col>
</Row>
</MenuItemContainer>
);
}
export default MenuItemGenerator;

View File

@@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Card, Form } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveView } from 'hooks/saveViews/useSaveView';
import { useNotifications } from 'hooks/useNotifications';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { SaveButton } from './styles';
import { SaveViewFormProps, SaveViewWithNameProps } from './types';
import { saveViewHandler } from './utils';
function SaveViewWithName({
sourcePage,
handlePopOverClose,
refetchAllView,
}: SaveViewWithNameProps): JSX.Element {
const [form] = Form.useForm<SaveViewFormProps>();
const { t } = useTranslation(['explorer']);
const { currentQuery, panelType, redirectWithQueryBuilderData } =
useQueryBuilder();
const { notifications } = useNotifications();
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
const { isLoading, mutateAsync: saveViewAsync } = useSaveView({
viewName: form.getFieldValue('viewName'),
compositeQuery,
sourcePage,
extraData: '',
});
const onSaveHandler = (): void => {
saveViewHandler({
compositeQuery,
handlePopOverClose,
extraData: '',
notifications,
panelType: panelType || PANEL_TYPES.LIST,
redirectWithQueryBuilderData,
refetchAllView,
saveViewAsync,
sourcePage,
viewName: form.getFieldValue('viewName'),
form,
});
};
return (
<Card>
<Typography>{t('name_of_the_view')}</Typography>
<Form form={form} onFinish={onSaveHandler} requiredMark>
<Form.Item
name={['viewName']}
required
rules={[
{
required: true,
message: 'Please enter view name',
},
]}
>
<Input placeholder="Enter Name" />
</Form.Item>
<SaveButton
htmlType="submit"
type="primary"
loading={isLoading}
data-testid="save-view-name-action-button"
>
Save
</SaveButton>
</Form>
</Card>
);
}
export default SaveViewWithName;

View File

@@ -0,0 +1,32 @@
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
export const viewMockData: ViewProps[] = [
{
id: 'view1',
name: 'View 1',
createdBy: 'User 1',
category: 'category 1',
compositeQuery: {} as ICompositeMetricQuery,
createdAt: '2021-07-07T06:31:00.000Z',
updatedAt: '2021-07-07T06:33:00.000Z',
extraData: '',
sourcePage: DataSource.TRACES,
tags: [],
updatedBy: 'User 1',
},
{
id: 'view2',
name: 'View 2',
createdBy: 'User 2',
category: 'category 2',
compositeQuery: {} as ICompositeMetricQuery,
createdAt: '2021-07-07T06:30:00.000Z',
updatedAt: '2021-07-07T06:30:00.000Z',
extraData: '',
sourcePage: DataSource.TRACES,
tags: [],
updatedBy: 'User 2',
},
];

View File

@@ -0,0 +1,9 @@
export const ExploreHeaderToolTip = {
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
text: 'More details on how to use query builder',
};
export const SaveButtonText = {
SAVE_AS_NEW_VIEW: 'Save as new view',
SAVE_VIEW: 'Save view',
};

View File

@@ -0,0 +1,29 @@
import { Button, Card, Col } from 'antd';
import styled, { CSSProperties } from 'styled-components';
export const ExplorerCardHeadContainer = styled(Card)`
margin: 1rem 0;
padding: 0;
`;
export const OffSetCol = styled(Col)`
text-align: right;
`;
export const SaveButton = styled(Button)`
&&& {
margin: 1rem 0;
width: 5rem;
}
`;
export const DropDownOverlay: CSSProperties = {
maxHeight: '20rem',
overflowY: 'auto',
width: '20rem',
padding: 0,
};
export const MenuItemContainer = styled(Card)`
padding: 0;
`;

View File

@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { DataSource } from 'types/common/queryBuilder';
import { viewMockData } from '../__mock__/viewData';
import ExplorerCard from '../ExplorerCard';
const historyReplace = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
useHistory: (): any => ({
...jest.requireActual('react-router-dom').useHistory(),
replace: historyReplace,
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));
jest.mock('hooks/saveViews/useGetAllViews', () => ({
useGetAllViews: jest.fn(() => ({
data: { data: { data: viewMockData } },
isLoading: false,
error: null,
isRefetching: false,
refetch: jest.fn(),
})),
}));
jest.mock('hooks/saveViews/useUpdateView', () => ({
useUpdateView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
jest.mock('hooks/saveViews/useDeleteView', () => ({
useDeleteView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
// Mock usePreferenceSync
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
usePreferenceSync: (): any => ({
preferences: {
columns: [],
formatting: {
maxLines: 1,
format: 'table',
fontSize: 'small',
version: 1,
},
},
loading: false,
error: null,
updateColumns: jest.fn(),
updateFormatting: jest.fn(),
}),
}));
describe('ExplorerCard', () => {
it('renders a card with a title and a description', () => {
render(
<MockQueryClientProvider>
<PreferenceContextProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</PreferenceContextProvider>
</MockQueryClientProvider>,
);
expect(screen.queryByText('Query Builder')).not.toBeInTheDocument();
});
it('renders a save view button', () => {
render(
<MockQueryClientProvider>
<PreferenceContextProvider>
<ExplorerCard sourcepage={DataSource.TRACES}>child</ExplorerCard>
</PreferenceContextProvider>
</MockQueryClientProvider>,
);
expect(screen.queryByText('Save view')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { DataSource } from 'types/common/queryBuilder';
import { viewMockData } from '../__mock__/viewData';
import MenuItemGenerator from '../MenuItemGenerator';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
}),
}));
jest.mock('antd', () => ({
...jest.requireActual('antd'),
useForm: jest.fn().mockReturnValue({
onFinish: jest.fn(),
}),
}));
describe('MenuItemGenerator', () => {
it('should render MenuItemGenerator component', () => {
const screen = render(
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].id}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].id}
refetchAllView={jest.fn()}
viewData={viewMockData}
sourcePage={DataSource.TRACES}
/>
</MockQueryClientProvider>,
);
expect(screen.getByText(viewMockData[0].name)).toBeInTheDocument();
});
it('should call onMenuItemSelectHandler on click of MenuItemGenerator', () => {
render(
<MockQueryClientProvider>
<MenuItemGenerator
viewName={viewMockData[0].name}
viewKey={viewMockData[0].id}
createdBy={viewMockData[0].createdBy}
uuid={viewMockData[0].id}
refetchAllView={jest.fn()}
viewData={viewMockData}
sourcePage={DataSource.TRACES}
/>
</MockQueryClientProvider>,
);
const spanElement = screen.getByRole('img', {
name: /delete view/i,
});
expect(spanElement).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { fireEvent, render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SaveViewWithName from '../SaveViewWithName';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.APPLICATION}/`,
}),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam', () => ({
useGetPanelTypesQueryParam: jest.fn(() => 'mockedPanelType'),
}));
jest.mock('hooks/saveViews/useSaveView', () => ({
useSaveView: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));
describe('SaveViewWithName', () => {
it('should render SaveViewWithName component', () => {
const screen = render(
<QueryClientProvider client={queryClient}>
<SaveViewWithName
sourcePage={DataSource.TRACES}
handlePopOverClose={jest.fn()}
refetchAllView={jest.fn()}
/>
</QueryClientProvider>,
);
expect(screen.getByText('Save')).toBeInTheDocument();
});
it('should call saveViewAsync on click of Save button', () => {
const screen = render(
<QueryClientProvider client={queryClient}>
<SaveViewWithName
sourcePage={DataSource.TRACES}
handlePopOverClose={jest.fn()}
refetchAllView={jest.fn()}
/>
</QueryClientProvider>,
);
fireEvent.click(screen.getByText('Save'));
expect(screen.getByText('Save')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,92 @@
import { UseMutateAsyncFunction } from 'react-query';
import { FormInstance } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { AxiosResponse } from 'axios';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
DeleteViewPayloadProps,
SaveViewPayloadProps,
SaveViewProps,
ViewProps,
} from 'types/api/saveViews/types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
export interface ExplorerCardProps {
sourcepage: DataSource;
children: React.ReactNode;
}
export type GetViewDetailsUsingViewKey = (
viewKey: string,
data: ViewProps[] | undefined,
) =>
| {
query: Query;
name: string;
id: string;
panelType: PANEL_TYPES;
extraData?: string;
}
| undefined;
export interface IsQueryUpdatedInViewProps {
viewKey: string;
data: ViewProps[] | undefined;
stagedQuery: Query | null;
currentPanelType: PANEL_TYPES | null;
options: OptionsQuery;
}
export interface SaveViewWithNameProps {
sourcePage: ExplorerCardProps['sourcepage'];
handlePopOverClose: VoidFunction;
refetchAllView: VoidFunction;
}
export interface SaveViewFormProps {
viewName: string;
}
export interface MenuItemLabelGeneratorProps {
viewName: string;
viewKey: string;
createdBy: string;
uuid: string;
viewData: ViewProps[];
refetchAllView: VoidFunction;
sourcePage: ExplorerCardProps['sourcepage'];
}
export interface SaveViewHandlerProps {
viewName: string;
compositeQuery: ICompositeMetricQuery;
sourcePage: ExplorerCardProps['sourcepage'];
extraData: string;
panelType: PANEL_TYPES | null;
notifications: NotificationInstance;
refetchAllView: SaveViewWithNameProps['refetchAllView'];
saveViewAsync: UseMutateAsyncFunction<
AxiosResponse<SaveViewPayloadProps>,
Error,
SaveViewProps,
SaveViewPayloadProps
>;
handlePopOverClose: SaveViewWithNameProps['handlePopOverClose'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
form: FormInstance<SaveViewFormProps>;
}
export interface DeleteViewHandlerProps {
deleteViewAsync: UseMutateAsyncFunction<DeleteViewPayloadProps, Error, string>;
refetchAllView: MenuItemLabelGeneratorProps['refetchAllView'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
notifications: NotificationInstance;
panelType: PANEL_TYPES | null;
viewKey: string;
viewId: string;
updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'];
sourcePage: ExplorerCardProps['sourcepage'];
}

View File

@@ -0,0 +1,204 @@
import type { NotificationInstance } from 'antd/es/notification/interface';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import isEqual from 'lodash-es/isEqual';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
DeleteViewHandlerProps,
GetViewDetailsUsingViewKey,
IsQueryUpdatedInViewProps,
SaveViewHandlerProps,
} from './types';
export const showErrorNotification = (
notifications: NotificationInstance,
err: Error,
): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
});
};
export const getViewDetailsUsingViewKey: GetViewDetailsUsingViewKey = (
viewKey,
data,
) => {
const selectedView = data?.find((view) => view.id === viewKey);
if (selectedView) {
const { compositeQuery, name, id, extraData } = selectedView;
const query = mapQueryDataFromApi(compositeQuery);
return { query, name, id, panelType: compositeQuery.panelType, extraData };
}
return undefined;
};
export const omitIdFromQuery = (query: Query | null): any => ({
...query,
builder: {
...query?.builder,
queryData: query?.builder.queryData.map((queryData) => {
const { id: _aggregateAttributeId, ...rest } =
queryData.aggregateAttribute || {};
const newAggregateAttribute = rest;
const newGroupByAttributes = queryData.groupBy.map((groupByAttribute) => {
const { id: _groupByAttributeId, ...rest } = groupByAttribute;
return rest;
});
const newItems = queryData.filters?.items?.map((item) => {
const { id: _itemId, ...newItem } = item;
if (item.key) {
const { id: _keyId, ...rest } = item.key;
return {
...newItem,
key: rest,
};
}
return newItem;
});
return {
...queryData,
aggregateAttribute: newAggregateAttribute,
groupBy: newGroupByAttributes,
filters: {
...queryData.filters,
items: newItems,
},
limit: queryData.limit ? queryData.limit : 0,
offset: queryData.offset ? queryData.offset : 0,
pageSize: queryData.pageSize ? queryData.pageSize : 0,
};
}),
},
});
export const isQueryUpdatedInView = ({
viewKey,
data,
stagedQuery,
currentPanelType,
options,
}: IsQueryUpdatedInViewProps): boolean => {
const currentViewDetails = getViewDetailsUsingViewKey(viewKey, data);
if (!currentViewDetails) {
return false;
}
const { query, panelType, extraData } = currentViewDetails;
// Omitting id from aggregateAttribute and groupBy
const updatedCurrentQuery = omitIdFromQuery(stagedQuery);
if (
updatedCurrentQuery?.builder === undefined ||
updatedCurrentQuery.clickhouse_sql === undefined ||
updatedCurrentQuery.promql === undefined
) {
return false;
}
return (
panelType !== currentPanelType ||
!isEqual(query.builder, updatedCurrentQuery?.builder) ||
!isEqual(query.clickhouse_sql, updatedCurrentQuery?.clickhouse_sql) ||
!isEqual(query.promql, updatedCurrentQuery?.promql) ||
!isEqual(
options?.selectColumns,
extraData && JSON.parse(extraData)?.selectColumns,
) ||
(stagedQuery?.builder?.queryData?.[0]?.dataSource === DataSource.LOGS &&
(!isEqual(options?.format, extraData && JSON.parse(extraData)?.format) ||
!isEqual(options?.maxLines, extraData && JSON.parse(extraData)?.maxLines) ||
!isEqual(options?.fontSize, extraData && JSON.parse(extraData)?.fontSize)))
);
};
export const saveViewHandler = ({
saveViewAsync,
refetchAllView,
notifications,
handlePopOverClose,
viewName,
compositeQuery,
sourcePage,
extraData,
redirectWithQueryBuilderData,
panelType,
form,
}: SaveViewHandlerProps): void => {
saveViewAsync(
{
viewName,
compositeQuery,
sourcePage,
extraData,
},
{
onSuccess: (data) => {
refetchAllView();
redirectWithQueryBuilderData(mapQueryDataFromApi(compositeQuery), {
[QueryParams.panelTypes]: panelType,
[QueryParams.viewName]: viewName,
[QueryParams.viewKey]: data.data.data,
});
notifications.success({
message: 'View Saved Successfully',
});
},
onError: (err) => {
showErrorNotification(notifications, err);
},
onSettled: () => {
handlePopOverClose();
form.resetFields();
},
},
);
};
export const deleteViewHandler = ({
deleteViewAsync,
refetchAllView,
redirectWithQueryBuilderData,
notifications,
panelType,
viewKey,
viewId,
updateAllQueriesOperators,
sourcePage,
}: DeleteViewHandlerProps): void => {
deleteViewAsync(viewKey, {
onSuccess: () => {
if (viewId === viewKey) {
redirectWithQueryBuilderData(
updateAllQueriesOperators(
initialQueriesMap[sourcePage],
panelType || PANEL_TYPES.LIST,
sourcePage,
),
{
[QueryParams.viewName]: '',
[QueryParams.panelTypes]: panelType,
[QueryParams.viewKey]: '',
},
);
}
notifications.success({
message: 'View Deleted Successfully',
});
refetchAllView();
},
onError: (err) => {
showErrorNotification(notifications, err);
},
});
};
export const trimViewName = (viewName: string): string => {
if (viewName.length > 20) {
return `${viewName.substring(0, 20)}...`;
}
return viewName;
};

View File

@@ -1,5 +1,5 @@
import { DrawerProps } from 'antd';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -4,7 +4,7 @@ import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import {
getBodyDisplayString,
getSanitizedLogBody,

View File

@@ -1,6 +1,6 @@
import { MouseEvent } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';

View File

@@ -18,6 +18,7 @@ import {
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { isScalarPanelType } from '../panelTypes';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -45,10 +46,7 @@ function QueryAggregationOptions({
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
if (isNonMetricSource && isScalarPanelType(panelType)) {
return { hidden: true, disabled: false, pinned: false };
}

View File

@@ -9,7 +9,6 @@ import {
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
@@ -27,6 +26,7 @@ import {
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import { isScalarPanelType } from './panelTypes';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -112,11 +112,7 @@ export const QueryV2 = forwardRef(function QueryV2(
}
const showReduceTo = useMemo(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
[dataSource, panelType],
);

View File

@@ -130,6 +130,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
updateAllQueriesOperators,
updateQueriesData,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
} as unknown as QueryBuilderContextType;

View File

@@ -0,0 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/** Panels that reduce each query to one value per group over the whole window. */
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.TABLE,
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -136,6 +136,7 @@ describe('MetricsSelect - signal source switching (standalone)', () => {
updateAllQueriesOperators: ((q: any) => q) as any,
updateQueriesData: ((q: any) => q) as any,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
});
});
@@ -292,6 +293,7 @@ describe('DataSource change - Logs to Traces', () => {
updateAllQueriesOperators: ((q: any) => q) as any,
updateQueriesData: ((q: any) => q) as any,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
});
});

View File

@@ -5,6 +5,7 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
@@ -48,9 +49,10 @@ function QuickFiltersLayout({
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);

View File

@@ -1,5 +1,6 @@
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
@@ -19,6 +20,10 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
@@ -65,6 +70,10 @@ const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -139,6 +148,35 @@ describe('QuickFiltersLayout', () => {
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout

View File

@@ -2,7 +2,6 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export { default as ColumnSelector } from './ColumnSelector';
export * from './TanStackHoverTooltip';
export * from './TanStackTableStateContext';
export * from './types';

View File

@@ -21,6 +21,7 @@ export enum LOCALSTORAGE {
CHAT_SUPPORT = 'CHAT_SUPPORT',
IS_IDENTIFIED_USER = 'IS_IDENTIFIED_USER',
DASHBOARD_VARIABLES = 'DASHBOARD_VARIABLES',
SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR',
PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES',
THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1',
LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS',
@@ -46,4 +47,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',

View File

@@ -24,7 +24,7 @@ export const REACT_QUERY_KEY = {
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
ALERT_RULES_CHART_PREVIEW: 'ALERT_RULES_CHART_PREVIEW',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
GET_HOST_LIST: 'GET_HOST_LIST',
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',

View File

@@ -50,6 +50,8 @@ const ROUTES = {
ROLE_EDIT: '/settings/roles/:roleId/edit',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',
TRACES_FUNNELS: '/traces/funnels',
TRACES_FUNNELS_DETAIL: '/traces/funnels/:funnelId',
WORKSPACE_LOCKED: '/workspace-locked',
@@ -67,6 +69,7 @@ const ROUTES = {
MESSAGING_QUEUES_OVERVIEW: '/messaging-queues/overview',
METRICS_EXPLORER: '/metrics-explorer/summary',
METRICS_EXPLORER_EXPLORER: '/metrics-explorer/explorer',
METRICS_EXPLORER_VIEWS: '/metrics-explorer/views',
METRICS_EXPLORER_VOLUME_CONTROL: '/metrics-explorer/volume-control',
API_MONITORING_BASE: '/api-monitoring',
API_MONITORING: '/api-monitoring/explorer',
@@ -74,6 +77,7 @@ const ROUTES = {
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
METER: '/meter',
METER_EXPLORER: '/meter/explorer',
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',

View File

@@ -0,0 +1,15 @@
.trigger {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
transition: transform 0.15s;
&:hover {
transform: scale(1.08);
}
&:active {
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
} from '../store/useAIAssistantStore';
import styles from './AIAssistantTrigger.module.scss';
/**
* Floating action button anchored to the bottom-right of the content area.
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
*/
export default function AIAssistantTrigger(): JSX.Element | null {
const { pathname } = useLocation();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.Icon,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
return (
<div className={`${styles.trigger} noz-wave`}>
<Tooltip title={NOZ_TOOLTIP_TITLE}>
<Button
size="md"
variant="solid"
color="primary"
icon
onClick={handleOpen}
aria-label="Open Noz"
>
<Noz size={24} />
</Button>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from './AIAssistantTrigger';
export { default } from './AIAssistantTrigger';

View File

@@ -102,7 +102,7 @@ describe('resolvePageType', () => {
expect(resolvePageType(ROUTES.METRICS_EXPLORER_EXPLORER, '')).toBe(
PageTypeDTO.metrics_explorer,
);
expect(resolvePageType('/metrics-explorer/views', '')).toBe(
expect(resolvePageType(ROUTES.METRICS_EXPLORER_VIEWS, '')).toBe(
PageTypeDTO.metrics_explorer,
);
});

View File

@@ -235,6 +235,7 @@ describe('buildExplorerNavigationUrl', () => {
{ queryType: 'builder' } as never,
{
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
[QueryParams.viewName]: 'My view',
[QueryParams.viewKey]: 'view-1',
},
);
@@ -258,7 +259,7 @@ describe('openSavedView', () => {
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
const params = new URLSearchParams(pushedUrl.split('?')[1]);
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
expect(params.get(QueryParams.viewName)).toBeNull();
expect(params.get(QueryParams.viewName)).toBe('"View view-logs"');
expect(params.get(QueryParams.panelTypes)).toBe('"list"');
});

View File

@@ -9,7 +9,7 @@ import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { History } from 'history';
@@ -108,6 +108,7 @@ export function openSavedView(
const query = getSavedViewQuery(view);
const url = buildExplorerNavigationUrl(route, query, {
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
[QueryParams.viewName]: view.spec.displayName,
[QueryParams.viewKey]: view.id,
});
history.push(url);

View File

@@ -13,7 +13,7 @@ import { matchPath } from 'react-router-dom';
*
* **URL-only.** All inputs come from `pathname` + `search`; we never reach
* into Redux / context. SigNoz already encodes most page state into the URL
* (`compositeQuery`, `startTime`/`endTime`, `viewKey`,
* (`compositeQuery`, `startTime`/`endTime`, `viewName`/`viewKey`,
* `variables`, `expandedWidgetId`, `activeLogId`, …), so URL parsing is
* sufficient. Anything not in the URL is simply omitted from `metadata` —
* the server applies its own defaults.
@@ -288,7 +288,7 @@ export function getAutoContexts(
// ── Metrics ───────────────────────────────────────────────────────────────
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer).
// Metrics explorer — `/metrics-explorer` and sub-routes (summary, explorer, views).
if (
matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_BASE, exact: false })
) {
@@ -361,6 +361,10 @@ function collectSharedMetadata(
if (viewKey) {
out.savedViewId = viewKey;
}
const viewName = params.get(QueryParams.viewName);
if (viewName) {
out.savedViewName = viewName;
}
const variablesRaw = params.get(QueryParams.variables);
if (variablesRaw) {
try {

View File

@@ -72,6 +72,40 @@
}
}
.chat-support-gateway {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
right: 20px;
z-index: 1000;
.chat-support-gateway-btn {
max-width: 48px;
width: 48px;
max-height: 48px;
height: 48px;
padding: 12px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background-color: #f25733;
border: none;
&:hover {
color: white !important;
border-color: white !important;
}
.chat-support-gateway-btn-icon {
fill: white;
}
}
}
.add-credit-card-btn,
.cancel-btn {
display: inline-flex;

View File

@@ -28,6 +28,7 @@ import getUserLatestVersion from 'api/v1/version/getLatestVersion';
import { AxiosError } from 'axios';
import cx from 'classnames';
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
@@ -50,6 +51,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -398,6 +401,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -485,6 +489,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}
}, [isDarkMode]);
const chatSupport = useChatSupport();
// Listen for API warnings
const handleWarning = (
isSlow: boolean,
@@ -828,7 +834,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</LayoutContent>
</Sentry.ErrorBoundary>
{isToDisplayLayout && !renderFullScreen && <BottomStrip />}
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (
@@ -839,6 +847,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
)}
</Flex>
{chatSupport === ChatSupportState.NeedsCard && !isSavedViewEnabled && (
<ChatSupportGateway />
)}
{showChangelogModal && changelog && (
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}

View File

@@ -101,16 +101,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
metadata: { team: 'ops' },
sendResolved: true,
},
telegram: {
apiUrl: 'https://api.telegram.org',
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 't',
message: 'm',
disableNotifications: true,
sendResolved: true,
},
};
describe('load then save round trip', () => {

View File

@@ -3,6 +3,7 @@ import { Color } from '@signozhq/design-tokens';
import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -51,9 +52,7 @@ export function getColorForThreshold(thresholdLabel: string): string {
if (thresholdLabel === 'info') {
return Color.BG_ROBIN_500;
}
const colorKeys = Object.keys(Color) as (keyof typeof Color)[];
const randomKey = colorKeys[Math.floor(Math.random() * colorKeys.length)];
return Color[randomKey];
return getRandomColor();
}
export function parseGoTime(input: string): {

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import ExplorerOptions, { ExplorerOptionsProps } from './ExplorerOptions';
import { getExplorerToolBarVisibility } from './utils';
type ExplorerOptionsWrapperProps = Omit<
ExplorerOptionsProps,
'isExplorerOptionDrop'
>;
function ExplorerOptionWrapper({
disabled,
query,
isLoading,
onExport,
sourcepage,
isOneChartPerQuery,
splitedQueries,
signalSource,
handleChangeSelectedView,
}: ExplorerOptionsWrapperProps): JSX.Element {
const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false);
useEffect(() => {
const toolbarVisibility = getExplorerToolBarVisibility(sourcepage);
setIsExplorerOptionHidden(!toolbarVisibility);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<ExplorerOptions
disabled={disabled}
query={query}
isLoading={isLoading}
onExport={onExport}
sourcepage={sourcepage}
signalSource={signalSource}
isExplorerOptionHidden={isExplorerOptionHidden}
setIsExplorerOptionHidden={setIsExplorerOptionHidden}
isOneChartPerQuery={isOneChartPerQuery}
splitedQueries={splitedQueries}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
}
export default ExplorerOptionWrapper;

View File

@@ -0,0 +1,234 @@
.explorer-options-container {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;
display: flex;
gap: 8px;
background-color: transparent;
.multi-alert-button,
.multi-dashboard-button {
min-width: 130px;
.ant-select-selector {
.ant-select-selection-placeholder {
margin-left: 0 !important;
}
.ant-select-arrow {
display: none !important;
}
}
}
&__divider {
--divider-vertical-margin: 0;
}
}
.hide-update {
left: calc(50% - 72px) !important;
}
.explorer-update {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: color-mix(in srgb, var(--l1-border) 60%, transparent);
border: 1px solid var(--l1-border);
border-radius: 4px;
backdrop-filter: blur(20px);
box-sizing: border-box;
.action-icon {
display: flex;
justify-content: center;
align-items: center;
padding: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
cursor: pointer;
box-shadow: none;
}
.hidden {
display: none;
}
}
.explorer-options {
padding: 10px 12px;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
backdrop-filter: blur(20px);
cursor: default;
display: flex;
gap: 16px;
z-index: 1;
.ant-select-selector {
padding: 0 !important;
}
hr {
border-color: var(--l1-border);
}
.view-options,
.actions {
.info-icon {
margin: 8px;
}
.hidden {
display: none;
}
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
.ant-select-focused {
border-color: transparent !important;
.ant-select-selector {
border-color: transparent !important;
box-shadow: none !important;
}
}
.ant-select-selector {
border: transparent !important;
background-color: transparent !important;
.ant-select-selection-placeholder {
margin-left: 12px;
}
}
}
.hidden {
display: none;
}
button {
display: flex;
align-items: center;
justify-content: center;
}
}
.app-content {
&.collapsed {
.explorer-options-container {
left: calc(50% + 72px);
}
}
}
.render-options {
display: flex;
align-items: center;
gap: 8px;
padding: 0 2px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.dot {
margin-left: 6px;
min-height: 6px;
min-width: 6px;
border-radius: 50%;
backdrop-filter: blur(20px);
}
}
.save-view-modal {
width: 384px !important;
.ant-modal-content {
padding: 0;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
padding: 16px;
background: var(--l2-background);
border-bottom: 1px solid var(--l1-border);
}
.ant-modal-body {
padding: 12px 16px 0px 16px;
.ant-typography {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.save-view-input {
margin-top: 8px;
display: flex;
gap: 8px;
}
.ant-color-picker-trigger {
padding: 6px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
width: 32px;
height: 32px;
.ant-color-picker-color-block {
border-radius: 50px;
width: 16px;
height: 16px;
flex-shrink: 0;
.ant-color-picker-color-block-inner {
display: flex;
justify-content: center;
align-items: center;
}
}
}
}
.ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 16px 16px;
margin: 0;
.save-button {
display: flex;
}
}
}
.title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
.explorer-option-droppable-container {
position: fixed;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: -webkit-fill-available;
height: 24px;
display: flex;
justify-content: center;
border-radius: 10px 10px 0px 0px;
// box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25);
// backdrop-filter: blur(20px);
.explorer-actions-btn {
display: flex;
gap: 8px;
margin-right: 8px;
.action-btn {
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px 10px 0px 0px;
box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(20px);
height: 24px !important;
border: none;
}
}
.explorer-show-btn {
border-radius: 6px 6px 0px 0px;
border: 1px solid var(--l1-border);
background: color-mix(in srgb, var(--l1-border) 40%, transparent);
box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(20px);
align-self: center;
padding: 8px 12px;
height: 24px !important;
.menu-bar {
border-radius: 50px;
background: var(--l3-background);
height: 4px;
width: 50px;
}
}
}

View File

@@ -0,0 +1,84 @@
import { Dispatch, SetStateAction } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Tooltip } from 'antd';
import { Disc3, X } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';
import { setExplorerToolBarVisibility } from './utils';
import './ExplorerOptionsHideArea.styles.scss';
interface DroppableAreaProps {
viewName: string;
isQueryUpdated: boolean;
isExplorerOptionHidden?: boolean;
sourcepage: DataSource;
setIsExplorerOptionHidden?: Dispatch<SetStateAction<boolean>>;
handleClearSelect: () => void;
onUpdateQueryHandler: () => void;
isEditDeleteSupported: boolean;
}
function ExplorerOptionsHideArea({
viewName,
isQueryUpdated,
isExplorerOptionHidden,
sourcepage,
setIsExplorerOptionHidden,
handleClearSelect,
onUpdateQueryHandler,
isEditDeleteSupported,
}: DroppableAreaProps): JSX.Element {
const handleShowExplorerOption = (): void => {
if (setIsExplorerOptionHidden) {
setIsExplorerOptionHidden(false);
setExplorerToolBarVisibility(true, sourcepage);
}
};
return (
<div className="explorer-option-droppable-container">
{isExplorerOptionHidden && (
<>
{viewName && (
<div className="explorer-actions-btn">
<Tooltip title="Clear this view">
<Button
onClick={handleClearSelect}
className="action-btn"
style={{ background: Color.BG_CHERRY_500 }}
icon={<X size={14} color={Color.BG_INK_500} />}
/>
</Tooltip>
{isEditDeleteSupported && isQueryUpdated && (
<Tooltip title="Update this View">
<Button
onClick={onUpdateQueryHandler}
className="action-btn"
style={{ background: Color.BG_ROBIN_500 }}
icon={<Disc3 size={14} color={Color.BG_INK_500} />}
/>
</Tooltip>
)}
</div>
)}
<Button
// style={{ alignSelf: 'center', marginRight: 'calc(10% - 20px)' }}
className="explorer-show-btn"
onClick={handleShowExplorerOption}
data-testid="show-explorer-option"
>
<div className="menu-bar" />
</Button>
</>
)}
</div>
);
}
ExplorerOptionsHideArea.defaultProps = {
isExplorerOptionHidden: undefined,
setIsExplorerOptionHidden: undefined,
};
export default ExplorerOptionsHideArea;

View File

@@ -0,0 +1,391 @@
import { useHistory } from 'react-router-dom';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { rest, server } from 'mocks-server/server';
import {
defaultFeatureFlags,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { buildExportPanelLink } from 'pages/DashboardPage/DashboardContainer/PanelEditor/newPanelRoute';
import ExplorerOptionWrapper from '../ExplorerOptionWrapper';
import { getExplorerToolBarVisibility } from '../utils';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(),
}));
jest.mock('../utils', () => ({
getExplorerToolBarVisibility: jest.fn(),
generateRGBAFromHex: jest.fn(() => 'rgba(0, 0, 0, 0.08)'),
getRandomColor: jest.fn(() => '#000000'),
saveNewViewHandler: jest.fn(),
setExplorerToolBarVisibility: jest.fn(),
DATASOURCE_VS_ROUTES: {},
}));
const mockGetExplorerToolBarVisibility = jest.mocked(
getExplorerToolBarVisibility,
);
const mockUseHistory = jest.mocked(useHistory);
// Mock data
const TEST_QUERY_ID = 'test-query-id';
const TEST_DASHBOARD_ID = 'test-dashboard-id';
const TEST_DASHBOARD_TITLE_2 = 'Test Dashboard for Export';
const NEW_DASHBOARD_ID = 'new-dashboard-id';
// The export dialog talks to the generated Perses-spec dashboard endpoints.
const V2_LIST_ENDPOINT = '*/api/v2/users/me/dashboards';
const V2_CREATE_ENDPOINT = '*/api/v2/dashboards';
// "Create new dashboard" names the dashboard from the `new_dashboard_title` i18n
// key; the test-utils react-i18next mock returns the key verbatim.
const NEW_DASHBOARD_TITLE = 'new_dashboard_title';
// Use the existing mock query from the codebase
const mockQuery: Query = {
...MOCK_QUERY,
id: TEST_QUERY_ID, // Override with our test ID
} as Query;
const ADD_TO_DASHBOARD_BUTTON_NAME = /add to dashboard/i;
interface PickerDashboard {
id: string;
title: string;
}
/** Register the "list dashboards" endpoint the picker reads. */
function mockDashboardList(dashboards: PickerDashboard[]): void {
server.use(
rest.get(V2_LIST_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
dashboards: dashboards.map(({ id, title }) => ({
id,
name: title,
spec: { display: { name: title } },
})),
reservedKeywords: [],
},
}),
),
),
);
}
/** Register the "create dashboard" endpoint the "New dashboard" button hits. */
function mockCreateDashboard(id: string): void {
server.use(
rest.post(V2_CREATE_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: { id } })),
),
);
}
// Helper function to render component with props
const renderExplorerOptionWrapper = (
overrides = {},
): ReturnType<typeof render> => {
const props = {
disabled: false,
query: mockQuery,
isLoading: false,
onExport: jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>,
sourcepage: DataSource.LOGS,
isOneChartPerQuery: false,
splitedQueries: [],
signalSource: 'test-signal',
...overrides,
};
return render(
<ExplorerOptionWrapper
disabled={props.disabled}
query={props.query}
isLoading={props.isLoading}
onExport={props.onExport}
sourcepage={props.sourcepage}
isOneChartPerQuery={props.isOneChartPerQuery}
splitedQueries={props.splitedQueries}
signalSource={props.signalSource}
/>,
undefined,
{ appContextOverrides: { featureFlags: defaultFeatureFlags } },
);
};
describe('ExplorerOptionWrapper', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetExplorerToolBarVisibility.mockReturnValue(true);
});
it('should navigate to alert creation page when "Create an Alert" is clicked in logs-explorer', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockPush = jest.fn();
mockUseHistory.mockReturnValue({
push: mockPush,
} as unknown as ReturnType<typeof useHistory>);
renderExplorerOptionWrapper({ sourcepage: DataSource.LOGS });
const createAlertButton = screen.getByRole('button', {
name: 'Create an Alert',
});
await user.click(createAlertButton);
expect(mockPush).toHaveBeenCalledTimes(1);
const calledWith = mockPush.mock.calls[0][0] as string;
const [path, search = ''] = calledWith.split('?');
expect(path).toBe('/alerts/new');
const params = new URLSearchParams(search);
expect(params.has('compositeQuery')).toBe(true);
});
describe('onExport functionality', () => {
it('should call onExport when New Dashboard button is clicked in export modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
mockCreateDashboard(NEW_DASHBOARD_ID);
renderExplorerOptionWrapper({ onExport: testOnExport });
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
});
await user.click(addToDashboardButton);
// Wait for the export modal to appear
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the "New dashboard" button
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
await user.click(newDashboardButton);
// Wait for the API call to complete and onExport to be called
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: NEW_DASHBOARD_ID, title: NEW_DASHBOARD_TITLE },
true,
);
});
});
it('should call onExport when selecting existing dashboard and clicking Export button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
// Mock existing dashboards with unique titles
const dashboards: PickerDashboard[] = [
{ id: 'dashboard-1', title: 'Dashboard 1' },
{ id: 'dashboard-2', title: 'Dashboard 2' },
];
mockDashboardList(dashboards);
renderExplorerOptionWrapper({ onExport: testOnExport });
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
});
await user.click(addToDashboardButton);
// Wait for the export modal to appear
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select to render AND finish loading. antd
// disables the Select while the list request is in flight, and a click
// on a disabled combobox is a no-op — so sync on the ready (enabled)
// state before clicking.
const modal = screen.getByRole('dialog');
await waitFor(() => {
const combobox = modal.querySelector('[role="combobox"]');
expect(combobox).toBeTruthy();
expect(combobox).not.toBeDisabled();
});
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
expect(dashboardSelect).toBeInTheDocument();
await user.click(dashboardSelect);
// Wait for the dropdown options to appear and select the first dashboard
await waitFor(() => {
expect(screen.getByText(dashboards[0].title)).toBeInTheDocument();
});
// Click on the first dashboard option
const dashboardOption = screen.getByText(dashboards[0].title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
await waitFor(() => {
expect(screen.getByTestId('export-panel-export')).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
await user.click(exportButton);
// Wait for onExport to be called with the selected dashboard
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: dashboards[0].id, title: dashboards[0].title },
false,
);
});
});
it('should navigate to the panel editor via the export link without writing the dashboard', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock the safeNavigate function
const mockSafeNavigate = jest.fn();
const panelTypeParam = PANEL_TYPES.TIME_SERIES;
const query = mockQuery;
// Create a real handleExport function similar to LogsExplorerViews
// Export navigates only; it must not write the dashboard (PR #8029).
const handleExport = (dashboard: ExportDashboard | null): void => {
if (!dashboard) {
return;
}
// Call the real link builder (not mocked)
const dashboardEditView = buildExportPanelLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
});
// Simulate navigation
mockSafeNavigate(dashboardEditView);
};
// Mock existing dashboards
const dashboards: PickerDashboard[] = [
{ id: TEST_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE_2 },
];
mockDashboardList(dashboards);
renderExplorerOptionWrapper({ onExport: handleExport });
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
});
await user.click(addToDashboardButton);
// Wait for the export modal to appear
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select to render AND finish loading (see note above).
const modal = screen.getByRole('dialog');
await waitFor(() => {
const combobox = modal.querySelector('[role="combobox"]');
expect(combobox).toBeTruthy();
expect(combobox).not.toBeDisabled();
});
const dashboardSelect = modal.querySelector(
'[role="combobox"]',
) as HTMLElement;
expect(dashboardSelect).toBeInTheDocument();
await user.click(dashboardSelect);
// Wait for the dropdown options to appear and select the dashboard
await waitFor(() => {
expect(screen.getByText(dashboards[0].title)).toBeInTheDocument();
});
// Click on the dashboard option
const dashboardOption = screen.getByText(dashboards[0].title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
await waitFor(() => {
expect(screen.getByTestId('export-panel-export')).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
// Click the export button
const exportButton = screen.getByTestId('export-panel-export');
await user.click(exportButton);
// Wait for the handleExport function to be called and navigation to occur
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
// V2 panel-editor link: compositeQuery is double-encoded (see newPanelRoute).
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${TEST_DASHBOARD_ID}/panel/new?panelKind=signoz%2FTimeSeriesPanel&compositeQuery=${encodeURIComponent(
encodeURIComponent(JSON.stringify(query)),
)}`,
);
});
});
});
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
// The "Add to Dashboard" button should be disabled
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
});
expect(addToDashboardButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,6 @@
export enum PreservedViewsTypes {
LOGS = 'logs',
TRACES = 'traces',
METRICS = 'metrics',
METER = 'meter',
}

View File

@@ -0,0 +1,47 @@
import { Dispatch, SetStateAction } from 'react';
import { UseMutateAsyncFunction } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { AxiosResponse } from 'axios';
import { SaveViewWithNameProps } from 'components/ExplorerCard/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { PreservedViewsTypes } from './constants';
export interface SaveNewViewHandlerProps {
viewName: string;
compositeQuery: ICompositeMetricQuery;
sourcePage: DataSource | 'meter';
extraData: SaveViewProps['extraData'];
panelType: PANEL_TYPES | null;
notifications: NotificationInstance;
refetchAllView: SaveViewWithNameProps['refetchAllView'];
saveViewAsync: UseMutateAsyncFunction<
AxiosResponse<SaveViewPayloadProps>,
Error,
SaveViewProps,
SaveViewPayloadProps
>;
handlePopOverClose: SaveViewWithNameProps['handlePopOverClose'];
redirectWithQueryBuilderData: QueryBuilderContextType['redirectWithQueryBuilderData'];
setNewViewName: Dispatch<SetStateAction<string>>;
}
export type PreservedViewType =
| PreservedViewsTypes.LOGS
| PreservedViewsTypes.TRACES
| PreservedViewsTypes.METRICS
| PreservedViewsTypes.METER;
export type PreservedViewsInLocalStorage = Partial<
Record<PreservedViewType, { key: string; value: string }>
>;
export type ChangeViewFunctionType = (
view: ExplorerViews,
querySearchParameters?: ICurrentQueryData,
) => void;

View File

@@ -0,0 +1,123 @@
import { Color } from '@signozhq/design-tokens';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { showErrorNotification } from 'components/ExplorerCard/utils';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { DataSource } from 'types/common/queryBuilder';
import { SaveNewViewHandlerProps } from './types';
export const getRandomColor = (): string => {
const colorKeys = Object.keys(Color) as (keyof typeof Color)[];
const randomKey = colorKeys[Math.floor(Math.random() * colorKeys.length)];
return Color[randomKey];
};
export const DATASOURCE_VS_ROUTES: Record<DataSource, string> = {
[DataSource.METRICS]: ROUTES.METRICS_EXPLORER_EXPLORER,
[DataSource.TRACES]: ROUTES.TRACES_EXPLORER,
[DataSource.LOGS]: ROUTES.LOGS_EXPLORER,
};
export const saveNewViewHandler = ({
saveViewAsync,
refetchAllView,
notifications,
handlePopOverClose,
viewName,
compositeQuery,
sourcePage,
extraData,
redirectWithQueryBuilderData,
panelType,
setNewViewName,
}: SaveNewViewHandlerProps): void => {
saveViewAsync(
{
viewName,
compositeQuery,
sourcePage: sourcePage as DataSource,
extraData,
},
{
onSuccess: (data) => {
refetchAllView();
redirectWithQueryBuilderData(mapQueryDataFromApi(compositeQuery), {
[QueryParams.panelTypes]: panelType,
[QueryParams.viewName]: viewName,
[QueryParams.viewKey]: data.data.data,
});
notifications.success({
message: 'View Saved Successfully',
});
},
onError: (err) => {
showErrorNotification(notifications, err);
},
onSettled: () => {
handlePopOverClose();
setNewViewName('');
},
},
);
};
export const generateRGBAFromHex = (hex: string, opacity: number): string =>
`rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(
hex.slice(3, 5),
16,
)}, ${parseInt(hex.slice(5, 7), 16)}, ${opacity})`;
export const getExplorerToolBarVisibility = (dataSource: string): boolean => {
try {
const showExplorerToolbar = getLocalStorageKey(
LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR,
);
if (showExplorerToolbar === null) {
const parsedShowExplorerToolbar: {
[DataSource.LOGS]: boolean;
[DataSource.TRACES]: boolean;
[DataSource.METRICS]: boolean;
} = {
[DataSource.METRICS]: true,
[DataSource.TRACES]: true,
[DataSource.LOGS]: true,
};
setLocalStorageKey(
LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR,
JSON.stringify(parsedShowExplorerToolbar),
);
return true;
}
const parsedShowExplorerToolbar = JSON.parse(showExplorerToolbar || '{}');
return parsedShowExplorerToolbar[dataSource];
} catch (error) {
console.error(error);
return false;
}
};
export const setExplorerToolBarVisibility = (
value: boolean,
dataSource: string,
): void => {
try {
const showExplorerToolbar = getLocalStorageKey(
LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR,
);
if (showExplorerToolbar) {
const parsedShowExplorerToolbar = JSON.parse(showExplorerToolbar);
parsedShowExplorerToolbar[dataSource] = value;
setLocalStorageKey(
LOCALSTORAGE.SHOW_EXPLORER_TOOLBAR,
JSON.stringify(parsedShowExplorerToolbar),
);
return;
}
} catch (error) {
console.error(error);
}
};

View File

@@ -22,26 +22,13 @@
min-width: 0;
}
/* Laid out like the alert pages: discard on the left, test and save together on
the right. Sticky rather than fixed, so it stays inside the page's own scroll
instead of adding another offset over the bottom strip. */
.actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l2-border);
background: var(--l1-background);
}
.actionGroup {
display: flex;
align-items: center;
gap: var(--spacing-3);
border-top: 1px solid var(--l1-border);
}
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
@@ -49,21 +36,3 @@
opacity: 0.5;
cursor: not-allowed;
}
/* The @signozhq inputs leave their surface transparent and take the card's
colour. antd paints its textareas instead, so beside them they read as a
different control, most visibly in light mode. */
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
.fields :global(textarea.ant-input),
.fields :global(textarea.ant-input:hover),
.fields :global(textarea.ant-input:focus),
.fields :global(textarea.ant-input-focused) {
background: var(--input-background, transparent);
border-width: var(--input-border-width, 1px);
border-style: var(--input-border-style, solid);
border-color: var(--input-border-color, var(--border));
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
box-shadow: var(--input-box-shadow, none);
color: inherit;
}

View File

@@ -1,8 +1,8 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
margin-bottom: var(--spacing-6);
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
}
.label {

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button } from '@signozhq/ui/button';
import { Input as AntdInput } from 'antd';
@@ -10,7 +10,6 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
const { TextArea } = AntdInput;
@@ -140,19 +139,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
<RowEntry
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
noun={t('entry_incidentio_metadata')}
removeLabel={t('button_incidentio_remove_metadata')}
removeTestId={`incidentio-metadata-remove-${index}`}
onRemove={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
>
<FormField label={t('label_incidentio_metadata_key')}>
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.row}>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
@@ -165,9 +155,8 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-key-${index}`}
/>
</FormField>
<FormField label={t('label_incidentio_metadata_value')}>
</div>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
@@ -180,10 +169,22 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-value-${index}`}
/>
</FormField>
</RowEntry>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('button_incidentio_remove_metadata')}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
testId={`incidentio-metadata-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
<div className={styles.add}>
<Button
variant="dashed"

View File

@@ -1,10 +1,3 @@
/* One height for every control a repeating row renders, so the remove button,
the add button and the inputs beside them agree. */
.entry,
.add {
--row-control-height: 28px;
}
.row {
display: flex;
align-items: center;
@@ -30,9 +23,7 @@
}
.add {
/* Sits with the blocks it adds to, not against the next field's label. */
margin-top: var(--spacing-2);
--button-height: var(--row-control-height);
margin-top: var(--spacing-1);
}
@media (max-width: 720px) {
@@ -44,52 +35,3 @@
max-width: none;
}
}
/* One entry per block, every input labelled. A row of bare boxes gave no way to
tell which column was which once a value was typed. The block keeps a readable
width while the rest of the form fills the page, so its two columns do not
stretch into a row of six. */
.entry {
max-width: 760px;
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--l2-background);
padding: var(--spacing-6) var(--spacing-7);
margin-bottom: var(--spacing-4);
}
.entryTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
/* The remove control is an icon button, so give it the height the design
system gives a small control rather than letting the glyph set it. */
--button-height: var(--row-control-height);
--button-icon-size: 14px;
}
.entryIndex {
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.entryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 var(--spacing-6);
}
/* The toggle reads as a sentence, so it takes the whole width rather than
sitting in a column sized for an input. */
.entryWide {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.entryGrid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -1,50 +0,0 @@
import { ReactNode } from 'react';
import { Minus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './RowEditor.module.scss';
interface RowEntryProps {
/** Zero-based; shown to the reader as a count from one. */
index: number;
/** Singular noun for one entry, such as "Field" or "Action". */
noun: string;
removeLabel: string;
removeTestId: string;
onRemove: () => void;
children: ReactNode;
}
function RowEntry({
index,
noun,
removeLabel,
removeTestId,
onRemove,
children,
}: RowEntryProps): JSX.Element {
return (
<div className={styles.entry}>
<div className={styles.entryTop}>
<Typography.Text size="sm" className={styles.entryIndex}>
{noun} {index + 1}
</Typography.Text>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={removeLabel}
onClick={onRemove}
testId={removeTestId}
>
<Minus size={14} />
</Button>
</div>
<div className={styles.entryGrid}>{children}</div>
</div>
);
}
export default RowEntry;

View File

@@ -42,9 +42,7 @@ function Slack(): JSX.Element {
>
<Input {...register('channel')} data-testid="slack-channel-textbox" />
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
@@ -84,12 +82,7 @@ function Slack(): JSX.Element {
)}
/>
</FormField>
</FormCard>
<FormCard
title={t('section_advanced')}
description={t('description_slack_advanced')}
>
<FormField
label={t('field_slack_color')}
help={t('help_slack_color')}

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
@@ -29,37 +29,49 @@ function SlackActions(): JSX.Element {
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
const labelFor = (column: ActionColumn): string =>
t(`label_slack_action_${column.replace('.text', '')}`);
return (
<FormField
label={t('field_slack_actions')}
tooltip={t('tooltip_slack_actions')}
>
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
<div>
{fields.map((row, index) => (
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_action')}
removeLabel={t('remove_slack_action')}
removeTestId={`slack-action-remove-${index}`}
onRemove={(): void => remove(index)}
>
{fields.length > 0 && (
<div className={styles.row}>
{COLUMNS.map((column) => (
<FormField
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
>
<div key={column} className={styles.field}>
<Typography.Text size="sm" className={styles.heading}>
{labelFor(column)}
</Typography.Text>
</div>
))}
</div>
)}
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<Input
{...register(`actions.${index}.${column}` as const)}
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
testId={`slack-action-${keyFor(column)}-${index}`}
placeholder={t(
`placeholder_slack_action_${column.replace('.text', '')}`,
)}
testId={`slack-action-${column.replace('.text', '')}-${index}`}
/>
</FormField>
</div>
))}
</RowEntry>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_action')}
onClick={(): void => remove(index)}
testId={`slack-action-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
</div>

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
@@ -8,7 +8,6 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
@@ -18,61 +17,52 @@ function SlackFields(): JSX.Element {
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
<FormField
label={t('field_slack_fields')}
tooltip={t('tooltip_slack_fields')}
>
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
<div>
{fields.map((row, index) => (
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_field')}
removeLabel={t('remove_slack_field')}
removeTestId={`slack-field-remove-${index}`}
onRemove={(): void => remove(index)}
>
<FormField
label={t('label_slack_field_title')}
tooltip={t('tooltip_slack_field_title')}
>
<div key={row.id} className={styles.row}>
<div className={styles.field}>
<Input
{...register(`fields.${index}.title`)}
placeholder={t('placeholder_slack_field_title')}
testId={`slack-field-title-${index}`}
/>
</FormField>
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
>
</div>
<div className={styles.field}>
<Input
{...register(`fields.${index}.value`)}
placeholder={t('placeholder_slack_field_value')}
testId={`slack-field-value-${index}`}
/>
</FormField>
<div className={styles.entryWide}>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
</div>
</RowEntry>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_field')}
onClick={(): void => remove(index)}
testId={`slack-field-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
</div>

View File

@@ -24,7 +24,6 @@ describe('FormAlertChannels', () => {
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
onDiscardHandler={jest.fn()}
testingState={false}
savingState={false}
editing

View File

@@ -1,7 +1,6 @@
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
@@ -49,12 +48,10 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -118,10 +115,7 @@ function FormAlertChannels({
/>
</FormField>
<FormField
label={t('field_send_resolved')}
tooltip={t('tooltip_send_resolved')}
>
<FormField label={t('field_send_resolved')}>
<Controller
control={control}
name="sendResolved"
@@ -137,11 +131,7 @@ function FormAlertChannels({
/>
</FormField>
<FormField
label={t('field_channel_type')}
tooltip={t('tooltip_channel_type')}
htmlFor="channel-type"
>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
@@ -158,45 +148,30 @@ function FormAlertChannels({
</fieldset>
<div className={styles.actions}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="discard-channel-button"
prefix={<X size={14} />}
onClick={onDiscardHandler}
>
{t('button_discard_channel')}
</Button>
<div className={styles.actionGroup}>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
disabled={!hasChanges}
disabledTooltip={t('tooltip_save_no_changes')}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
</div>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
</div>
</form>
</FormProvider>
@@ -209,15 +184,12 @@ interface FormAlertChannelsProps {
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
onDiscardHandler: () => void;
testingState: boolean;
savingState: boolean;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
hasChanges?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
@@ -227,7 +199,6 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

View File

@@ -10,8 +10,9 @@ import {
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { SOURCEPAGE_VS_ROUTES } from 'container/SavedViews/constants';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import Card from 'periscope/components/Card/Card';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
@@ -35,6 +36,7 @@ export default function SavedViews({
loadingUserPreferences: boolean;
}): JSX.Element {
const { user } = useAppContext();
const isSavedViewEnabled = useSavedViewEnabled();
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
const [selectedEntityViews, setSelectedEntityViews] = useState<
SavedviewtypesSavedViewDTO[]
@@ -102,6 +104,7 @@ export default function SavedViews({
view.spec.panelType,
{
query: getSavedViewQuery(view),
viewName: view.spec.displayName,
viewKey: view.id,
},
SOURCEPAGE_VS_ROUTES[selectedEntity],
@@ -114,6 +117,19 @@ export default function SavedViews({
}
}, [hasSavedViews, onUpdateChecklistDoneItem, loadingUserPreferences]);
const footerLink = useMemo(() => {
if (selectedEntity === 'logs') {
return ROUTES.LOGS_SAVE_VIEWS;
}
if (selectedEntity === 'traces') {
return ROUTES.TRACES_SAVE_VIEWS;
}
if (selectedEntity === 'metrics') {
return ROUTES.METRICS_EXPLORER_VIEWS;
}
return '';
}, [selectedEntity]);
const getStartedLink = useMemo(() => {
if (selectedEntity === 'logs') {
return ROUTES.LOGS_EXPLORER;
@@ -336,6 +352,26 @@ export default function SavedViews({
<Card.Content>
{selectedEntityViews.length > 0 ? renderSavedViews() : emptyStateCard()}
</Card.Content>
{!isSavedViewEnabled && selectedEntityViews.length > 0 && (
<Card.Footer>
<div className="services-footer home-data-card-footer">
<Link to={footerLink}>
<Button
type="link"
className="periscope-btn link learn-more-link"
onClick={(): void => {
logEvent('Homepage: All saved views clicked', {
entity: selectedEntity,
});
}}
>
All Views <ArrowRight size={12} />
</Button>
</Link>
</div>
</Card.Footer>
)}
</Card>
);
}

View File

@@ -23,6 +23,7 @@
.traces-explorer-views {
padding: 8px;
padding-bottom: 60px;
margin-bottom: 24px;
.ant-tabs-tabpane {

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
@@ -225,9 +226,11 @@ function Explorer(): JSX.Element {
}
/>
</div>
<div className="query-section-container">
<QuerySection />
</div>
<ExplorerCard sourcepage={DataSource.TRACES}>
<div className="query-section-container">
<QuerySection />
</div>
</ExplorerCard>
<div className="traces-explorer-views">
{isCancelled && (

View File

@@ -1,7 +1,6 @@
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { formUrlParams } from 'utils/traceUtils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
@@ -21,7 +20,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
}
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
tab: TraceDetailsTab.Thread,
spanId,
levelUp: 0,
levelDown: 0,

View File

@@ -3,7 +3,6 @@ import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { getMs } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
@@ -45,10 +44,7 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={{
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
search: `?tab=${TraceDetailsTab.Thread}`,
}}
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>

View File

@@ -2,7 +2,6 @@ import userEvent from '@testing-library/user-event';
import { logEventMock } from '__tests__/logEventMock';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { server } from 'mocks-server/server';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -122,26 +121,22 @@ describe('ListAlertRules — actions menu', () => {
expect(options).toStrictEqual(expect.objectContaining({ newTab: true }));
});
it('clone action: sends POST to clone endpoint and opens the cloned rule returned by the API', async () => {
let capturedRuleId: string | null = null;
it('clone action: sends POST with " - Copy" suffix and opens the cloned rule returned by the API', async () => {
let capturedPostBody: unknown = null;
server.use(
rest.post(
'http://localhost/api/v2/rules/:id/clone',
async (req, res, ctx) => {
capturedRuleId = req.params.id as string;
return res(
ctx.status(201),
ctx.json({
data: {
...alertRulesFixture[0],
id: 'cloned-from-server',
alert: 'High CPU Alert - Copy',
},
status: 'success',
}),
);
},
),
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
capturedPostBody = await req.json();
return res(
ctx.status(201),
ctx.json({
data: {
...(capturedPostBody as Record<string, unknown>),
id: 'cloned-from-server',
},
status: 'success',
}),
);
}),
);
renderListAlertRules();
@@ -150,7 +145,9 @@ describe('ListAlertRules — actions menu', () => {
await clickMenuItem('Clone');
await waitFor(() => {
expect(capturedRuleId).toBe('rule-1');
expect(capturedPostBody).toStrictEqual(
expect.objectContaining({ alert: 'High CPU Alert - Copy' }),
);
});
// The id from the server response round-trips into the navigate URL — this

View File

@@ -6,10 +6,10 @@ import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
invalidateListRules,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
@@ -62,9 +62,10 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
const handleClone = useCallback((): void => {
alertActionLogEvent(ALERT_ACTIONS.CLONE, rule);
toast.promise(
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
createRule({
...rule,
alert: `${rule.alert} - Copy`,
} as RuletypesPostableRuleDTO).then(async (response) => {
await invalidateListRules(queryClient);
const newRule = response.data;
if (newRule) {

View File

@@ -3,21 +3,23 @@ import { Columns3 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import type { TableColumnDef } from 'components/TanStackTableView';
import {
hideColumn,
showColumn,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './ColumnSelector.module.scss';
import type { TableColumnDef } from './types';
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
interface ColumnSelectorProps<TData> {
columns: TableColumnDef<TData>[];
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
storageKey: string;
testId?: string;
}
function ColumnSelector<TData>({
columns,
storageKey,
testId = 'column-selector-button',
}: ColumnSelectorProps<TData>): JSX.Element {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
@@ -45,7 +47,7 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
testId={testId}
testId="alert-columns-button"
>
Columns
</Button>
@@ -75,8 +77,4 @@ function ColumnSelector<TData>({
);
}
ColumnSelector.defaultProps = {
testId: 'column-selector-button',
};
export default ColumnSelector;

View File

@@ -1 +1,2 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu } from './components';
import { ActionsMenu, ColumnSelector } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
@@ -134,11 +134,7 @@ function ListAlertRules(): JSX.Element {
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
{addNewAlert && (
<Button
variant="solid"

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