Compare commits

...

5 Commits

Author SHA1 Message Date
Ashwin Bhatkal
8ee9f97f28 feat(dashboards): gate the dashboards list on fine-grained authz (#12449)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

Applies the authz list-page pattern to the dashboards list and gates the
row actions per dashboard.

- **The table is blocked** without `list`, via an inline callout. **New
dashboard stays enabled** if the caller holds `create` — it is
independent of `list`.
- **Everything that only feeds the table goes with it.** The search box
and the filter chips edit a query that can only be run through the list
request, so they are disabled rather than hidden, and each carries the
standard denial on hover. Two tooltip zones cover them — the search
field, and the chips with Clear — instead of one tooltip per control.
- **The views rail is one guarded block**, not a column of dead
controls. Every view leads to a table the caller cannot see, and the
saved views behind it need the same grant, so the rail carries a single
callout. That also drops an empty state that was asserting something it
could not know ("No saved views yet" when the request was never made).
- **Row actions run their own checks** through `AuthZButton`, so the
menu opens immediately. View, Open in New Tab and Copy Link need no
permission and no longer wait behind the gated rows; all nine rows now
render through the same component rather than three of them being plain
buttons.
- **Delete gates on `delete` alone** (guide rule 3), so it survives
without `read`. **Rows are never gated** (rule 4): the list is
collection-scoped and deliberately returns rows the caller cannot
`read`, so every row stays clickable and the denial is explained on
arrival.
- **Per-row checks fire lazily**, when a menu opens rather than for all
20 rows, and the menu itself renders lazily — it previously evaluated
its whole tree and instantiated two mutations per row on every render.
- **Viewers can manage saved views again.** Save, rename and delete were
behind `edit_dashboard`; the backend only requires `list`, and the views
request now follows the same grant.
- **The row menu's lock gates on the dashboard's `source`**, not on who
created it — an integration dashboard can never be locked, and
everything else is decided by `update`.
- **Route gating**: the three dashboard routes bypass the legacy role
check, so an FGA user with no managed role isn't bounced to
`/un-authorized`.
- **Guide updated to match.** The list-page rule previously said to
leave the search, filters and views interactive. It now says to disable
what only shapes a blocked request, and to gate a region once rather
than repeat a tooltip per control.

#### Screenshots
<img width="1920" height="992" alt="Screenshot 2026-09-10 at 11 13
20 PM"
src="https://github.com/user-attachments/assets/b590ddcd-4b7b-4e82-8a51-ef5ffae68623"
/>

<img width="1571" height="275" alt="Screenshot 2026-09-10 at 11 14
09 PM"
src="https://github.com/user-attachments/assets/f0a6f32c-ff1d-4a9c-81ae-8e3ae4832ecf"
/>

<img width="570" height="363" alt="Screenshot 2026-09-10 at 11 22 32 PM"
src="https://github.com/user-attachments/assets/2f42e918-ae06-499e-8700-5deba46b987a"
/>
<img width="547" height="320" alt="Screenshot 2026-09-10 at 11 16 41 PM"
src="https://github.com/user-attachments/assets/b5e287b1-9178-4165-866f-e914348d6e39"
/>
<img width="518" height="345" alt="Screenshot 2026-09-10 at 11 17 00 PM"
src="https://github.com/user-attachments/assets/ac22cd2f-2aa1-499f-b3cf-4f9014bf6ea3"
/>
<img width="498" height="313" alt="Screenshot 2026-09-10 at 11 18 45 PM"
src="https://github.com/user-attachments/assets/72c252f5-5616-4a44-b645-3074a6d9fc12"
/>
<img width="561" height="311" alt="Screenshot 2026-09-10 at 11 18 52 PM"
src="https://github.com/user-attachments/assets/923f5c48-b272-4278-9951-8a9122b2732c"
/>
<img width="588" height="322" alt="Screenshot 2026-09-10 at 11 16 31 PM"
src="https://github.com/user-attachments/assets/5061c115-57b4-4fdd-aa9d-9f43cd539be6"
/>

 

#### Additional Information

- `DASHBOARD` and `DASHBOARD_PANEL_EDITOR` are registered for authz
here, though their pages ship in #12438. Harmless in this order — until
this PR merges those routes keep their legacy role checks, which is the
fail-safe direction.
- Two callouts show when `list` is denied, one on the rail and one where
the table would be. They are separate blocks with separate reasons to
exist, but it is more denial than the guide's "state it once".
- A 403 from the list request still renders as "Invalid query", because
`ErrorState` treats every 4xx as a client error. Pre-existing and not
authz-specific, so left alone.
- Saved views are org-shared with no ownership rule, so anyone holding
`list` can delete anyone's view. Unchanged here — flagging it as a
product question.
- Requires #12438. Rebased onto current `main`; shares
`utils/permission/index.ts` and `AppRoutes/__tests__/Private.test.tsx`
with #12448, which merges without conflict.
2026-09-11 03:54:05 +00:00
Ashwin Bhatkal
878e938b65 feat(dashboards): show disabled dashboard actions with a reason (#12438)
#### Description

Moves the dashboard detail page off the deprecated role table and onto
per-action authz checks. The change reviewers will notice first: **a
control you can't use is now visible and disabled with the reason,
instead of missing.**

- **Every control goes through `AuthZButton` or `AuthZTooltip`** —
toolbar, Actions menu, section and panel menus, the variables `+`,
settings tabs, publish, the JSON drawer, the panel editor's Save. Denial
wording comes from the components, so there is one tooltip
implementation in the tree.
- **Dropdown rows are real buttons.** Each row renders an `AuthZButton`,
the same as the dashboards list, so the authz component disables it like
any other control. The dropdown *item* keeps its own `disabled`,
`onClick` and `danger` — the menu needs the first two for keyboard
navigation and the last for the destructive colour — and the row carries
only its icon, label and reason.
- **The row's padding belongs to the button.** The menu hands its item
padding to the rows, as the list page's popover already does, so a row's
box reaches the panel edge. Left on the item, the button the tooltip
anchors to was a box inset 16px inside the panel and the reason opened
underneath it.
- **The edit context carries no permission copy.** It hands out the
checks a control needs plus the non-permission obstacle for
`disabledTooltip`, left empty when a permission is what's missing. That
produces `update` → lock → `read`: someone without access hears about
the access, not the lock, which would send them asking for an unlock.
- **The page gates `read` alone** — the only verb the dashboard request
needs — and preloads `update`/`delete` in the same batch, so the
controls below resolve from cache in one round trip instead of holding
up the page.
- **Publishing is no longer admin-only.** It gates on `update`, so an
editor can manage a public link (guide rule 2).
- **Fixes the settings drawer** rendering tabs from
`Object.values(TabKeys)` instead of `items`, which ignored both
`disabled` and the `enablePublicDashboard` flag — on OSS the Publish
trigger rendered and landed on an empty body.
- **Fixes a clone gate**: `canReadDashboard` was aliased from
`canEditDashboard`, which is read *and* update, so read + create without
update was wrongly refused.

#### Screenshots
<img width="557" height="314" alt="Screenshot 2026-09-10 at 11 24 23 PM"
src="https://github.com/user-attachments/assets/565d85bf-6099-4bdc-8921-9dfc162bb730"
/>
<img width="824" height="336" alt="Screenshot 2026-09-10 at 11 24 18 PM"
src="https://github.com/user-attachments/assets/66976143-936d-4255-ac03-0599112a97d8"
/>

<img width="824" height="328" alt="Screenshot 2026-09-10 at 11 24 00 PM"
src="https://github.com/user-attachments/assets/86cdeb1c-314f-4dd9-b63e-acb4b435ab6f"
/>
<img width="1642" height="769" alt="Screenshot 2026-09-10 at 11 25
11 PM"
src="https://github.com/user-attachments/assets/b23c4ee9-0d14-4e41-9099-76daa779f62a"
/>
<img width="932" height="343" alt="Screenshot 2026-09-10 at 11 24 50 PM"
src="https://github.com/user-attachments/assets/c8b9367d-5a59-45d2-8f85-af8ebdfdc51b"
/>

<img width="677" height="991" alt="Screenshot 2026-09-10 at 11 24 30 PM"
src="https://github.com/user-attachments/assets/c7dd2851-e874-48f2-a74b-d526a65cefdb"
/>

#### Additional Information

- Delete is unreachable without `read` here, because `withAuthZPage`
replaces the whole page. Agreed as a dashboards exception — delete stays
available from the list's row actions.
- **Configure stays disabled without `update`.** Opening the drawer
read-only needs every field inside it to go read-only too — the Overview
form, the tag inputs, and a view mode for the variables table — which is
a larger change than this PR should carry. Planned as a follow-up once
the three merge.
- Worth a visual check on the three dropdowns: their rows changed from
library-rendered items to full-width buttons, which tests can't see.
- Requires #12448. Rebased onto current `main`; disjoint from #12449.
2026-09-11 03:07:11 +00:00
Ashwin Bhatkal
206aad1acd feat(authz): add dashboard permissions (#12448)
#### Description

Registers the `dashboard` authz kind and adds the shared pieces the two
dashboard PRs stack on. No dashboard UI changes here.

- **Registers `dashboard`** in `cmd/authz.go` and regenerates
`permissions.type.ts`. `public-dashboard` is deliberately *not*
registered: those routes are open access, the only verb any of them
checks is an anonymous read seeded by migration, and nothing in the
frontend builds a permission against the kind.
- **Adds a Dashboards panel** to the custom-role editor. The map stays a
total `Record`, so a newly registered resource still has to be given a
panel — its length assertions are that guard.
- **`disabledTooltip` on `AuthZTooltip` / `AuthZButton`**, for a block
the consumer already knows about that is *not* a permission — a lock, an
integration dashboard, a deliberately read-only mount. It takes
precedence over `checks`, which are then **skipped entirely** rather
than fetched for a control that is unavailable either way. Callers set
it only when that block is the real obstacle, so a missing permission
still surfaces its own standard wording. That is how `update` ends up
outranking the lock.
- **`preloadChecks` on the HOCs**, fetched in the same batch as `checks`
and never gated on, so a control below the guard resolves from cache
instead of firing a second round trip and flipping once it lands.
`AuthZGuard` is untouched.
- `AuthZTooltip` also takes `side` and `tooltipMessage`, and keeps its
reason up when the dead control is clicked — Radix closes on both
pointerdown and click, and merges its own handlers after the trigger's
regardless of `preventDefault`, so the close is filtered in the
component.
- **Dashboard permission hooks**: `canEdit` is `read && update` per
guide rule 2, `read` reports loading separately from the rest, and the
lock hook checks `dashboard:update` plus the one non-permission rule the
handler adds — an integration dashboard can never be locked.

#### Screenshots / Screen Recordings

<img width="1435" height="945" alt="Screenshot 2026-09-10 at 11 32
56 PM"
src="https://github.com/user-attachments/assets/560ae704-e83a-4207-bceb-ada7a5e75a78"
/>

<img width="1421" height="678" alt="Screenshot 2026-09-10 at 11 32
41 PM"
src="https://github.com/user-attachments/assets/4d02dcda-e985-4db7-b77a-433b6de23db5"
/>

#### Additional Information

- **Denial copy comes from the components.** There is no dashboard
permission-message module any more; the non-permission strings live in
the i18n bundle. `useIsOrgAdmin` goes with it — the lock was its only
caller.
- The lock hook no longer considers who created the dashboard — access
is decided by role. **The handler still enforces creator-or-admin**, so
that check wants removing backend-side too, or a non-creator with
`update` gets an enabled Unlock and a 403.
- The grant-all MSW default is gone: each suite states the permissions
it needs, so an authz test can't pass for the wrong reason.
- Stacked: #12438 then #12449, and rebased onto current `main`. The
three squash-merge in order with no conflicts — verified by replaying
the whole sequence locally, including the two files this PR shares with
#12449 (`utils/permission/index.ts` and
`AppRoutes/__tests__/Private.test.tsx`, additions ~24 and ~68 lines
apart). Each branch above needs a rebase after the one below merges.
2026-09-11 02:40:48 +00:00
Vinicius Lourenço
f6cd4d31b4 feat(storybook): add the story screenshot sweep and diff (#12799)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

This add a tool to help you take screenshot of the UI using the
Storybook Stories, and also run a diff similar to Chromatic UI.

The idea is to speedup local comparisons and then when you think it's
ready/good enough, then we open a PR and actually run the Chromatic.

All the screenshots taken will be stored at `frontend/.story-shots`.

Eg of a diff with green as highlight for the changes:

<img width="1680" height="1200" alt="pages-home--loading"
src="https://github.com/user-attachments/assets/0c9b8ae1-4709-44a1-a9e8-3268e2b2a485"
/>
2026-09-10 19:41:28 +00:00
Aditya Singh
2387266df5 feat(quick-filters): section hover actions + duration reset (UX) (#12760)
#### Description

- Section headers now expose search and reset icons on hover only. The
always-visible "Filter values" input is gone.. it renders only via the
hover search icon, and closing it clears the search text
- Reset shows on hover for every section (per the views + bottom strip
design), not only when that key has an active filter
- Long section names ellipsize, and hovering shows the full name in a
tooltip. Header height + collapse chevron don't shift when the hover
actions come in
- Duration filter gets the same hover reset in its header, replacing the
old always-visible "Clear All"
- Fixed two bugs in Duration's clear: it silently did nothing
(rebuilding the filter then stripping it behind a no-op guard on the
wrong query source), and after sharing the util its >= / <= clauses
survived in the expression so the filter came back. The shared
clearFilterFromQuery takes the operator list from the caller now..
checkbox keeps its managed set, duration clears the key entirely
- Extracted a shared SectionActionButton (tooltip + icon button) and
moved clearFilterFromQuery into a shared module so the checkbox and
duration paths use one implementation

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5978

#### Screenshots



https://github.com/user-attachments/assets/7ac85b0d-faf4-4910-a3a4-0f6e2ce465d7
2026-09-10 17:49:09 +00:00
131 changed files with 5529 additions and 1583 deletions

View File

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

View File

@@ -96,6 +96,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,

3
frontend/.gitignore vendored
View File

@@ -33,3 +33,6 @@ e2e/test-plan/user-preferences/
# Storybook
/storybook-static/
debug-storybook.log
# Storybook screenshot sweeps (scripts/story-shots.mjs)
/.story-shots/

View File

@@ -576,6 +576,17 @@
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"
}
},
{
// Dev-tooling CLIs: stdout is their output, and they carry ported pixel/heap
// algorithms that read worse when split up.
"files": [
"scripts/**"
],
"rules": {
"no-console": "off",
"sonarjs/cognitive-complexity": "off"
}
}
]
}

View File

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

View File

@@ -2,6 +2,7 @@ import type { Preview } from '@storybook/react-vite';
import type { SetupWorker } from 'msw';
import { setupWorker } from 'msw';
import { settleForCapture } from '../src/storybook/visual/settleForCapture';
import { withProviders } from '../src/storybook/decorators/withProviders';
import { globalMocks } from '../src/storybook/globals';
import { resetStoryHistory } from '../src/storybook/navigation/containment';
@@ -10,6 +11,7 @@ import {
resolveStory,
type StoryRuntimeContext,
} from '../src/storybook/runtime/resolveStory';
import { allModes } from './modes';
import '../src/ReactI18';
@@ -65,6 +67,12 @@ const preview: Preview = {
parameters: {
layout: 'fullscreen',
controls: { expanded: true },
// One cloud snapshot per theme, for every story. A mode carries Storybook
// globals, so `theme` here is the same toolbar global the app reads out of
// localStorage. Widths are Chromatic's only real dimension, as they are
// locally: the app shell sizes itself to the viewport, so the height is the
// one it is given.
chromatic: { modes: allModes },
},
globalTypes: {
theme: {
@@ -79,8 +87,21 @@ const preview: Preview = {
dynamicTitle: true,
},
},
motion: {
description:
'Park every animation on its last frame once the story has settled. Still is what both capture stacks shoot; Live is for watching a transition.',
toolbar: {
title: 'Motion',
icon: 'play',
items: [
{ value: 'still', title: 'Still' },
{ value: 'live', title: 'Live' },
],
dynamicTitle: true,
},
},
},
initialGlobals: { theme: 'dark' },
initialGlobals: { theme: 'dark', motion: 'still' },
// Controls every story carries: permissions, banners, and whether the page's
// own endpoints answer, hang or fail.
args: globalMocks.args,
@@ -105,6 +126,8 @@ const preview: Preview = {
clearBlockedNavigations();
resetStoryHistory();
},
// After `play`, which is the moment both capture stacks shoot at.
afterEach: settleForCapture,
};
export default preview;

View File

@@ -34,6 +34,9 @@ These hold for every page. The per-pattern sections below only add to them.
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
detail route).
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
- **Gate a page on `read` alone.** It is the only verb the page's own request needs. `update` and `delete` gate
individual controls, so waiting for them holds up the whole page for nothing — pass them as `preloadChecks` and
they resolve in the same request, leaving the controls to read from cache.
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
@@ -45,11 +48,20 @@ These hold for every page. The per-pattern sections below only add to them.
![Visual rules for structuring a list page](./assets/list-example.svg)
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
Without `list`, but with any of `read` / `create` / `update`, the table and
everything that only feeds it are blocked:
- Title, description, search filters and action buttons stay visible.
- Filters and any control that drives the table are non-interactive.
- The create button stays enabled if the user holds `create`.
- Title, description, search, filters and action buttons stay visible. Nothing is
hidden for lack of permission.
- Disable what only shapes the blocked request — the search box, the filter chips,
a Clear button — and give it the same denial. It edits a query that has nowhere
to run, so leaving it live invites the user to compose a filter and watch
nothing happen.
- Gate a region as one section when several of its controls are dead. A saved
views rail is a block with a single callout, not a column of identical
tooltips; a row of filters is one tooltip zone, not one per control.
- The create button stays enabled if the user holds `create`. It is independent
of `list`.
### Edit page
@@ -96,6 +108,11 @@ Blocking is always a visible denial, never a silent removal. Use the components
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
denial message and the loading state.
**Denial copy comes from the components.** Never write a custom message for a permission check. `disabledTooltip` is
for a block that is *not* a permission — a lock, an immutable resource, a mount that is deliberately read-only. It
takes precedence over the checks, which are then skipped, so set it only when that block is the real obstacle: a
missing permission must still surface its own wording.
| Scope | Component | Denied state |
| --------------- | ----------------------------------------- | ------------------------------------------- |
| Button | `AuthZButton` | Disabled + tooltip |

View File

@@ -25,11 +25,13 @@
"dashboard_has_been_updated": "Dashboard has been updated",
"do_you_want_to_refresh_the_dashboard": "Do you want to refresh the dashboard?",
"locked_dashboard_delete_tooltip_admin_author": "Dashboard is locked. Please unlock the dashboard to enable delete.",
"locked_dashboard_delete_tooltip_editor": "Dashboard is locked. Please contact admin to delete the dashboard.",
"delete_dashboard_success": "{{name}} dashboard deleted successfully",
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
"your_graph_build_with": "Your graph built with",
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
"variable_name_already_exists": "Variable \"{{name}}\" already exists",
"dashboard_locked": "This dashboard is locked",
"dashboard_read_only_here": "This dashboard is read-only here",
"lock_integration_dashboard": "An integration dashboard cannot be locked or unlocked"
}

View File

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

View File

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

514
frontend/scripts/story-shots.mjs Executable file
View File

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

View File

@@ -1558,6 +1558,18 @@ describe('PrivateRoute', () => {
keyof typeof routeWithInitialAuthZSupport,
AuthzRouteCase
> = {
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
DASHBOARD: {
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),
deniedRoles: DENIED_ROLES,
},
DASHBOARD_PANEL_EDITOR: {
path: ROUTES.DASHBOARD_PANEL_EDITOR.replace(
':dashboardId',
'dashboard-id-1',
).replace(':panelId', 'panel-id-1'),
deniedRoles: DENIED_ROLES,
},
// Everything under /settings resolves to the non-exact SETTINGS route
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },

View File

@@ -11,10 +11,10 @@ import {
import {
applyCheckboxToggle,
clearFilterFromQuery,
deriveCheckboxState,
getNotInOperator,
} from './checkboxFilterQuery';
import { clearFilterFromQuery } from '../shared/filterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
@@ -505,7 +505,7 @@ describe('clearFilterFromQuery', () => {
const result = clearFilterFromQuery({
currentQuery: query,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
filterKey: KEY,
activeQueryIndex: 0,
});
@@ -523,4 +523,35 @@ describe('clearFilterFromQuery', () => {
expect(other.filters?.items).toHaveLength(1);
expect(other.filter?.expression).toBe(`${KEY} = 'a'`);
});
it('without an operators list, clears non-managed clauses too (duration >= / <=)', () => {
const query = {
builder: {
queryData: [
{
filters: {
items: [
toTagItem({ key: 'durationNano', op: '>=', value: 5000000 }, 0),
toTagItem({ key: 'durationNano', op: '<=', value: 9000000 }, 1),
],
op: 'AND',
},
filter: {
expression: `durationNano >= 5000000 AND durationNano <= 9000000 AND http.method = 'GET'`,
},
},
],
},
} as unknown as Query;
const result = clearFilterFromQuery({
currentQuery: query,
filterKey: 'durationNano',
activeQueryIndex: 0,
});
const active = result.builder.queryData[0];
expect(active.filters?.items).toStrictEqual([]);
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
});
});

View File

@@ -23,7 +23,12 @@ export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// The operators this algebra emits, and so the only ones it may rewrite out of an
// expression. A hand-written clause on the same key (CONTAINS, EXISTS, a range) is
// none of its business and has to survive a toggle.
const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
export const MANAGED_OPERATORS = [
OPERATORS['='],
OPERATORS['!='],
'in',
'not in',
];
/**
* Drops this filter's own clauses for `key` from `expression`, leaving every other
@@ -31,7 +36,7 @@ const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
* prefixes, since `isKeyMatch` treats `service.name` and `resource.service.name` as
* the same filter but expression rewrites match keys literally.
*/
function removeManagedClauses(expression: string, key: string): string {
export function removeManagedClauses(expression: string, key: string): string {
return removeKeysFromExpression(
expression,
getKeySpellings(key),
@@ -124,49 +129,6 @@ export function deriveCheckboxState({
return filterState;
}
/**
* Returns a new query with this filter's clauses for the attribute key removed from
* the active query, both from the structured filter items and the raw expression.
*/
export function clearFilterFromQuery({
currentQuery,
filter,
activeQueryIndex,
}: {
currentQuery: Query;
filter: IQuickFiltersConfig;
activeQueryIndex: number;
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
expression: removeManagedClauses(
item.filter?.expression ?? '',
filter.attributeKey.key,
),
},
filters: {
...item.filters,
items:
item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || [],
op: item.filters?.op || 'AND',
},
};
}),
},
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyCheckboxToggle({
currentQuery,

View File

@@ -4,13 +4,11 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isFunction } from 'lodash-es';
import { isEqual, isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
} from './checkboxFilterQuery';
import { applyCheckboxToggle, MANAGED_OPERATORS } from './checkboxFilterQuery';
import { clearFilterFromQuery } from '../shared/filterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
@@ -94,7 +92,17 @@ function useCheckboxFilterActions({
};
const onClear = (): void => {
dispatch(clearFilterFromQuery({ currentQuery, filter, activeQueryIndex }));
const clearedQuery = clearFilterFromQuery({
currentQuery,
filterKey: filter.attributeKey.key,
activeQueryIndex,
operators: MANAGED_OPERATORS,
});
// Nothing to clear; no dispatch
if (isEqual(clearedQuery, currentQuery)) {
return;
}
dispatch(clearedQuery);
};
return { onChange, onClear };

View File

@@ -6,6 +6,44 @@
border-bottom: 1px solid var(--l1-border);
}
.sectionActions {
width: 0;
overflow: hidden;
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease,
width 0s linear 0.16s;
}
.checkboxFilter:hover .sectionActions,
.sectionActions.sectionActionsPinned {
width: auto;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.sectionActionsPinned .sectionActionHoverOnly {
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.search {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import classNames from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
@@ -44,8 +45,14 @@ export default function CheckboxFilterV2(
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const [searchText, setSearchText] = useState<string>('');
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const handleToggleSearch = (): void => {
setIsSearchOpen((prev) => !prev);
setSearchText('');
};
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
@@ -164,12 +171,15 @@ export default function CheckboxFilterV2(
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
showClearAll={!!attributeValues.length}
onToggleOpen={onToggleOpen}
onClear={onClear}
isSomeFilterPresentForCurrentAttribute={
isSomeFilterPresentForCurrentAttribute
actionsClassName={classNames(styles.sectionActions, {
[styles.sectionActionsPinned]: isSearchOpen,
})}
resetActionClassName={
isSearchOpen ? styles.sectionActionHoverOnly : undefined
}
onToggleOpen={onToggleOpen}
onToggleSearch={handleToggleSearch}
onClear={onClear}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
<section>
@@ -178,23 +188,26 @@ export default function CheckboxFilterV2(
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
<section className={styles.search}>
<Input
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
data-testid="checkbox-filter-search"
suffix={
isFetching ? (
<LoaderCircle
size={14}
className={styles.searchSpinner}
data-testid="checkbox-filter-search-loading"
/>
) : null
}
/>
</section>
{isSearchOpen && (
<section className={styles.search}>
<Input
autoFocus
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}
data-testid="checkbox-filter-search"
suffix={
isFetching ? (
<LoaderCircle
size={14}
className={styles.searchSpinner}
data-testid="checkbox-filter-search-loading"
/>
) : null
}
/>
</section>
)}
{totalCount > 0 && (
<section className={styles.values}>

View File

@@ -3,12 +3,20 @@
align-items: center;
justify-content: space-between;
cursor: pointer;
gap: var(--spacing-2);
}
.leftAction {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 1 1 auto;
min-width: 0;
// The collapse chevron must keep its size; only the title absorbs the squeeze.
> svg {
flex-shrink: 0;
}
}
.title {
@@ -18,16 +26,18 @@
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
// Always ellipsize a long name; on hover the actions take width and it
// compresses further.
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rightAction {
display: flex;
align-items: center;
min-width: 48px;
}
.clearAll {
font-size: 12px;
color: var(--accent-primary);
cursor: pointer;
gap: var(--spacing-1);
flex-shrink: 0;
}

View File

@@ -1,25 +1,47 @@
import { useState } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
import classNames from 'classnames';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
showClearAll: boolean;
actionsClassName?: string;
resetActionClassName?: string;
onToggleOpen: () => void;
onToggleSearch: () => void;
onClear: () => void;
isSomeFilterPresentForCurrentAttribute: boolean;
}
export function CheckboxFilterV2Header({
title,
isOpen,
showClearAll,
actionsClassName,
resetActionClassName,
onToggleOpen,
onToggleSearch,
onClear,
isSomeFilterPresentForCurrentAttribute,
}: CheckboxFilterHeaderProps): JSX.Element {
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
const measureTitle = (el: HTMLElement | null): void => {
if (el) {
setIsTitleTruncated(el.scrollWidth > el.clientWidth);
}
};
const titleText = (
<Typography.Text ref={measureTitle} className={styles.title}>
{title}
</Typography.Text>
);
return (
<section
role="button"
@@ -40,23 +62,31 @@ export function CheckboxFilterV2Header({
) : (
<ChevronRight size={13} cursor="pointer" />
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</section>
<section className={styles.rightAction}>
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
<Typography.Text
className={styles.clearAll}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
data-testid="checkbox-filter-clear-all"
>
Clear
</Typography.Text>
{isTitleTruncated ? (
<TooltipSimple title={title} delayDuration={400}>
{titleText}
</TooltipSimple>
) : (
titleText
)}
</section>
{isOpen && (
<section className={classNames(styles.rightAction, actionsClassName)}>
<SectionActionButton
icon={<Undo2 size={14} />}
className={resetActionClassName}
tooltip="Reset"
onClick={onClear}
testId="checkbox-filter-clear-all"
/>
<SectionActionButton
icon={<Search size={14} />}
tooltip="Search"
onClick={onToggleSearch}
testId="checkbox-filter-search-toggle"
/>
</section>
)}
</section>
);
}

View File

@@ -60,6 +60,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -143,6 +144,7 @@ describe('CheckboxFilterV2 - interactions', () => {
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -192,6 +194,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -236,6 +239,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
@@ -343,6 +347,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-pod-a-v1');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
@@ -515,7 +520,7 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides clear button when no filter applied for attribute', async () => {
it('shows the reset action when expanded even with no active filter', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
@@ -530,9 +535,45 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
// Reset is always available on an expanded section now (hover-gated via
// CSS), not conditional on an active filter.
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('does not dispatch on clear when the key has no filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: buildQueryBuilderOverrides({
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: '' },
},
],
},
}),
},
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onFilterChange).not.toHaveBeenCalled();
});
it('calls onFilterChange when clear clicked', async () => {

View File

@@ -110,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { CheckboxFilterV2Header } from '../CheckboxFilterV2Header';
@@ -7,9 +8,8 @@ describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
showClearAll: true,
isSomeFilterPresentForCurrentAttribute: true,
onToggleOpen: jest.fn(),
onToggleSearch: jest.fn(),
onClear: jest.fn(),
};
@@ -31,11 +31,12 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not show clear button when collapsed', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
);
it('does not render the section actions when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
expect(
screen.queryByTestId('checkbox-filter-search-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
@@ -50,36 +51,13 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'open');
});
it('shows clear button when expanded + showClearAll=true', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
it('renders both search and reset actions when expanded', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
expect(
screen.getByTestId('checkbox-filter-search-toggle'),
).toBeInTheDocument();
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
expect(screen.getByText('Clear')).toBeInTheDocument();
});
it('hides clear button when showClearAll=false', () => {
render(
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
it('hides clear button when no filter present for attribute', () => {
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
showClearAll
isSomeFilterPresentForCurrentAttribute={false}
/>,
);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
});
});
@@ -122,28 +100,35 @@ describe('CheckboxFilterV2Header', () => {
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onClear on clear button click', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
);
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onClear).toHaveBeenCalledTimes(1);
});
it('clear button click does not trigger onToggleOpen', async () => {
it('calls onToggleSearch on search click without toggling open', async () => {
const user = userEvent.setup();
const onToggleSearch = jest.fn();
const onToggleOpen = jest.fn();
const onClear = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleSearch={onToggleSearch}
onToggleOpen={onToggleOpen}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
expect(onToggleSearch).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
it('calls onClear on reset click without toggling open', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onClear={onClear}
onToggleOpen={onToggleOpen}
/>,
);
@@ -153,4 +138,51 @@ describe('CheckboxFilterV2Header', () => {
expect(onToggleOpen).not.toHaveBeenCalled();
});
});
describe('title tooltip', () => {
// jsdom has no layout, so truncation is simulated at the prototype level
// before mount (the component measures in a layout effect).
function mockTitleWidths(scrollWidth: number, clientWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockReturnValue(scrollWidth);
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockReturnValue(clientWidth);
}
afterEach(() => {
jest.restoreAllMocks();
});
it('shows the full name on hover when the title is truncated', async () => {
mockTitleWidths(200, 100);
const user = userEvent.setup();
render(
<TooltipProvider>
<CheckboxFilterV2Header {...defaultProps} />
</TooltipProvider>,
);
await user.hover(screen.getByText(defaultProps.title));
await expect(screen.findByRole('tooltip')).resolves.toHaveTextContent(
defaultProps.title,
);
});
it('shows no tooltip when the title fits', async () => {
mockTitleWidths(100, 100);
const user = userEvent.setup();
render(
<TooltipProvider>
<CheckboxFilterV2Header {...defaultProps} />
</TooltipProvider>,
);
await user.hover(screen.getByText(defaultProps.title));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});
});

View File

@@ -11,6 +11,21 @@
padding-right: 9px !important;
}
.duration-reset {
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
&:hover .duration-reset {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.ant-collapse-header-text {
color: var(--l2-foreground);
font-family: Inter;
@@ -105,11 +120,6 @@
.section-body-header {
display: flex;
> button {
position: absolute;
right: 4px;
padding-top: 13px;
}
.ant-collapse {
width: 100%;
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Collapse } from 'antd';
import { Collapse } from 'antd';
import { Undo2 } from '@signozhq/icons';
import {
IQuickFiltersConfig,
QuickFiltersSource,
@@ -14,12 +15,16 @@ import {
AllTraceFilterKeys,
AllTraceFilterKeyValue,
HandleRunProps,
traceFilterKeys,
unionTagFilterItems,
} from 'pages/TracesExplorer/Filter/filterUtils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { clearFilterFromQuery } from '../shared/filterQuery';
import { SectionActionButton } from '../shared/SectionActionButton/SectionActionButton';
import './Duration.styles.scss';
export type FilterType = Record<
@@ -268,12 +273,19 @@ function Duration({
handleRun();
}, [selectedFilters]);
const onClearHandler = (e: React.MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
if (selectedFilters?.durationNanoMin || selectedFilters?.durationNanoMax) {
handleRun({ clearByType: 'durationNano' });
const onClearHandler = (): void => {
if (!selectedFilters?.durationNanoMin && !selectedFilters?.durationNanoMax) {
return;
}
const clearedQuery = clearFilterFromQuery({
currentQuery,
filterKey: traceFilterKeys.durationNano.key,
activeQueryIndex,
});
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(clearedQuery);
} else {
redirectWithQueryBuilderData(clearedQuery);
}
};
@@ -294,18 +306,19 @@ function Duration({
/>
),
label: 'Duration',
extra: activeKeys.includes('durationNano') ? (
<div className="duration-reset">
<SectionActionButton
icon={<Undo2 size={14} />}
tooltip="Reset"
onClick={onClearHandler}
testId="collapse-duration-clearBtn"
/>
</div>
) : undefined,
},
]}
/>
{activeKeys.includes('durationNano') && (
<Button
type="link"
onClick={onClearHandler}
data-testid="collapse-duration-clearBtn"
>
Clear All
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
min-width: 24px;
height: 24px;
}

View File

@@ -0,0 +1,43 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from 'antd';
import classNames from 'classnames';
import styles from './SectionActionButton.module.scss';
interface SectionActionButtonProps {
icon: ReactNode;
tooltip: string;
onClick: () => void;
testId: string;
className?: string;
}
export function SectionActionButton({
icon,
tooltip,
onClick,
testId,
className,
}: SectionActionButtonProps): JSX.Element {
return (
<Tooltip title={tooltip}>
<Button
variant="link"
color="secondary"
size="sm"
className={classNames(styles.iconBtn, className)}
onMouseDown={(e): void => e.preventDefault()}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
onClick();
}}
data-testid={testId}
>
{icon}
</Button>
</Tooltip>
);
}

View File

@@ -0,0 +1,53 @@
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getKeySpellings, isKeyMatch } from '../Checkbox/utils';
/**
* Returns a new query with this filter's clauses for the attribute key removed from
* the active query, both from the structured filter items and the raw expression.
* `operators` limits which expression clauses are removed; omit to remove every
* clause on the key (e.g. duration's >= / <=).
*/
export function clearFilterFromQuery({
currentQuery,
filterKey,
activeQueryIndex,
operators,
}: {
currentQuery: Query;
filterKey: string;
activeQueryIndex: number;
operators?: string[];
}): Query {
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
expression: removeKeysFromExpression(
item.filter?.expression ?? '',
getKeySpellings(filterKey),
false,
operators,
),
},
filters: {
...item.filters,
items:
item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filterKey),
) || [],
op: item.filters?.op || 'AND',
},
};
}),
},
};
}

View File

@@ -329,9 +329,10 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(9);
expect(result).toHaveLength(10);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
'license',
'logs',
'meter-metrics',
@@ -420,9 +421,10 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(9);
expect(result).toHaveLength(10);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
'license',
'logs',
'meter-metrics',

View File

@@ -4,6 +4,7 @@ import {
DraftingCompass,
FileKey,
Gauge,
Grid3X3,
Key,
Logs,
Receipt,
@@ -41,6 +42,14 @@ export interface ResourcePanelConfig {
* not all of them
*/
export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
dashboard: {
label: 'Dashboards',
description: 'Dashboards and their panels across the workspace.',
icon: Grid3X3,
selectorPlaceholder:
'Type dashboard ID, separate multiple with comma or space',
docsAnchor: 'dashboard',
},
'factor-api-key': {
label: 'API Keys',
description: 'Programmatic access tokens for the workspace.',

View File

@@ -0,0 +1,137 @@
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { rest } from 'msw';
import { server } from 'mocks-server/server';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import {
buildDashboardReadPermission,
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { useDashboardPermissions } from '../useDashboardPermissions';
const DASHBOARD_ID = 'dash-1';
describe('useDashboardPermissions - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('permission granted', () => {
it('resolves every verb when all are granted', async () => {
server.use(setupAuthzAdmin());
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
wrapper: AllTheProviders,
});
await waitFor(() =>
expect(result.current.isReadPermissionLoading).toBe(false),
);
expect(result.current.canRead).toBe(true);
expect(result.current.canUpdate).toBe(true);
expect(result.current.canDelete).toBe(true);
expect(result.current.canEdit).toBe(true);
});
});
describe('permission denied', () => {
it('resolves every verb as false when all are denied', async () => {
server.use(setupAuthzDenyAll());
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
wrapper: AllTheProviders,
});
await waitFor(() =>
expect(result.current.isReadPermissionLoading).toBe(false),
);
expect(result.current.canRead).toBe(false);
expect(result.current.canUpdate).toBe(false);
expect(result.current.canDelete).toBe(false);
expect(result.current.canEdit).toBe(false);
});
});
describe('partial', () => {
// Authz guide rule 2: an edit affordance needs read as well as update.
it('denies canEdit when update is granted but read is not', async () => {
server.use(setupAuthzAllow(buildDashboardUpdatePermission(DASHBOARD_ID)));
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
wrapper: AllTheProviders,
});
await waitFor(() =>
expect(result.current.isReadPermissionLoading).toBe(false),
);
expect(result.current.canUpdate).toBe(true);
expect(result.current.canRead).toBe(false);
expect(result.current.canEdit).toBe(false);
});
it('denies canEdit when read is granted but update is not', async () => {
server.use(setupAuthzAllow(buildDashboardReadPermission(DASHBOARD_ID)));
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
wrapper: AllTheProviders,
});
await waitFor(() =>
expect(result.current.isReadPermissionLoading).toBe(false),
);
expect(result.current.canRead).toBe(true);
expect(result.current.canUpdate).toBe(false);
expect(result.current.canEdit).toBe(false);
});
});
describe('check failure', () => {
// An authz outage must not read as a denial — callers fall open and let the
// API decide, matching AuthZGuard's onFailRenderContent default.
it('reports hasError and grants nothing when the check fails', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
);
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
wrapper: AllTheProviders,
});
// A check that cannot answer is not a grant.
await waitFor(() =>
expect(result.current.isReadPermissionLoading).toBe(false),
);
expect(result.current.canRead).toBe(false);
expect(result.current.canEdit).toBe(false);
});
});
describe('disabled', () => {
it('fires no check when disabled', async () => {
const onCheck = jest.fn();
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
onCheck();
const payload = await req.json();
return res(
ctx.status(200),
ctx.json({ data: payload, status: 'success' }),
);
}),
);
renderHook(() => useDashboardPermissions(DASHBOARD_ID, { enabled: false }), {
wrapper: AllTheProviders,
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onCheck).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,31 @@
import {
DashboardCreatePermission,
DashboardListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export interface DashboardCollectionPermissions {
canList: boolean;
canCreate: boolean;
isLoading: boolean;
/**
* The check itself failed. Callers should fall open — behave as before authz
* and let the API decide — rather than treat an outage as a denial.
*/
hasError: boolean;
}
// Module-level so the useQueries identity stays stable across renders.
const CHECKS = [DashboardListPermission, DashboardCreatePermission];
/** Collection-level dashboard permissions (wildcard selector). */
export function useDashboardCollectionPermissions(): DashboardCollectionPermissions {
const { isGranted, isLoading, error } = useAuthZ(CHECKS);
return {
canList: isGranted(DashboardListPermission),
canCreate: isGranted(DashboardCreatePermission),
isLoading,
hasError: !!error,
};
}

View File

@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next';
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardPermissions } from './useDashboardPermissions';
export interface DashboardLockPermission {
canToggleLock: boolean;
isLoading: boolean;
/** Non-permission obstacle only; empty when a permission is what's missing. */
disabledTooltip: string;
}
/**
* Needs `dashboard:update`, and the handler then rejects integration dashboards;
* ownership is not a factor. It rejects system dashboards too, unchecked here
* because the list query filters them out.
*/
export function useDashboardLockPermission({
dashboardId,
source,
enabled = true,
}: {
dashboardId: string;
source: DashboardtypesSourceDTO;
enabled?: boolean;
}): DashboardLockPermission {
const { t } = useTranslation('dashboard');
const { canEdit, areOtherPermissionsLoading } = useDashboardPermissions(
dashboardId,
{ enabled },
);
const isLockable = source !== DashboardtypesSourceDTO.integration;
return {
canToggleLock: !areOtherPermissionsLoading && isLockable && canEdit,
isLoading: areOtherPermissionsLoading,
// Empty without `canEdit` so the missing permission surfaces instead.
disabledTooltip:
canEdit && !isLockable ? t('lock_integration_dashboard') : '',
};
}

View File

@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export interface DashboardPermissions {
canRead: boolean;
canUpdate: boolean;
canDelete: boolean;
/** Per the authz guide, an edit affordance needs `read` as well as `update`. */
canEdit: boolean;
/** `read` renders the dashboard, so a page gates its mount on this alone. */
isReadPermissionLoading: boolean;
/** `update`/`delete` gate controls only; the page does not wait on them. */
areOtherPermissionsLoading: boolean;
readPermission: BrandedPermission;
updatePermission: BrandedPermission;
deletePermission: BrandedPermission;
/** `[read, update]`, so a denial names both. */
editChecks: BrandedPermission[];
}
/**
* Resource-level dashboard permissions. Pass `enabled: false` while the id is
* unknown, so no check fires against an empty selector.
*/
export function useDashboardPermissions(
dashboardId: string,
options?: { enabled?: boolean },
): DashboardPermissions {
const enabled = options?.enabled ?? true;
const { readPermission, updatePermission, deletePermission } = useMemo(
() => ({
readPermission: buildDashboardReadPermission(dashboardId),
updatePermission: buildDashboardUpdatePermission(dashboardId),
deletePermission: buildDashboardDeletePermission(dashboardId),
}),
[dashboardId],
);
const checks = useMemo(
() => [readPermission, updatePermission, deletePermission],
[readPermission, updatePermission, deletePermission],
);
const { isGranted, isLoading } = useAuthZ(checks, { enabled });
const canRead = isGranted(readPermission);
const canUpdate = isGranted(updatePermission);
const canDelete = isGranted(deletePermission);
const editChecks = useMemo(
() => [readPermission, updatePermission],
[readPermission, updatePermission],
);
return {
canRead,
canUpdate,
canDelete,
canEdit: canRead && canUpdate,
// One request covers all three, so the two flags only differ when a page
// preloads `read` ahead of the rest — see `preloadChecks`.
isReadPermissionLoading: isLoading,
areOtherPermissionsLoading: isLoading,
readPermission,
updatePermission,
deletePermission,
editChecks,
};
}

View File

@@ -0,0 +1,62 @@
import { useMutation, useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import {
getGetDashboardV2QueryKey,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import APIError from 'types/api/error';
interface Args {
dashboardId: string;
isLocked: boolean;
/** Called with the new lock state — for analytics and any extra invalidation. */
onSuccess?: (locked: boolean) => void;
/** Called on failure — for rolling back optimistic state. */
onError?: (error: APIError) => void;
}
export interface ToggleDashboardLock {
toggleLock: () => void;
isTogglingLock: boolean;
}
/**
* Toggles a dashboard's lock and patches the detail-page cache, which runs
* `staleTime: Infinity` + `refetchOnMount: false` and would otherwise show the
* stale state. Only the flag is patched: a refetch would reload every panel.
*/
export function useToggleDashboardLock({
dashboardId,
isLocked,
onSuccess,
onError,
}: Args): ToggleDashboardLock {
const queryClient = useQueryClient();
const { mutate, isLoading } = useMutation({
mutationFn: () =>
isLocked
? unlockDashboardV2({ id: dashboardId })
: lockDashboardV2({ id: dashboardId }),
onSuccess: () => {
const next = !isLocked;
toast.success(next ? 'Dashboard locked' : 'Dashboard unlocked');
const key = getGetDashboardV2QueryKey({ id: dashboardId });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
});
}
onSuccess?.(next);
},
onError: (error: APIError) => {
onError?.(error);
},
});
return { toggleLock: mutate, isTogglingLock: isLoading };
}

View File

@@ -53,22 +53,6 @@ describe('AuthZButton', () => {
});
});
it('forwards a custom tooltipMessage', () => {
render(
<AuthZButton
checks={[createPerm]}
tooltipMessage="Ask an admin"
testId="create-btn"
>
Create
</AuthZButton>,
);
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
tooltipMessage: 'Ask an admin',
});
});
it('passes authZEnabled through as the tooltip enabled flag', () => {
render(
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">

View File

@@ -7,14 +7,19 @@ export type AuthZButtonProps = ButtonProps & {
* Permissions required to enable the button (AND semantics).
*/
checks: BrandedPermission[];
/**
* Override the default denial tooltip message.
*/
tooltipMessage?: string;
/**
* Gate the permission check itself. When false, renders a plain button.
*/
authZEnabled?: boolean;
/** Replace the standard denial wording; prefer the default. */
tooltipMessage?: string;
/**
* A non-permission block the consumer already knows about — a lock, an
* immutable resource. Takes precedence over `checks`, which are then skipped.
*/
disabledTooltip?: string;
/** Which side of the button to render the tooltip against. */
side?: 'top' | 'bottom' | 'left' | 'right';
/**
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
* otherwise the tooltip will not have the correct z-index
@@ -24,8 +29,10 @@ export type AuthZButtonProps = ButtonProps & {
function AuthZButton({
checks,
tooltipMessage,
authZEnabled = true,
tooltipMessage,
disabledTooltip,
side,
withPortal,
...buttonProps
}: AuthZButtonProps): JSX.Element {
@@ -34,6 +41,8 @@ function AuthZButton({
checks={checks}
enabled={authZEnabled}
tooltipMessage={tooltipMessage}
disabledTooltip={disabledTooltip}
side={side}
withPortal={withPortal}
>
<Button {...buttonProps} />

View File

@@ -4,6 +4,7 @@ import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
import type {
AuthZObject,
BrandedPermission,
UseAuthZResult,
} from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import AuthZTooltip from './AuthZTooltip';
@@ -11,15 +12,26 @@ import AuthZTooltip from './AuthZTooltip';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const noPermissions = {
isLoading: false,
isFetching: false,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [] as BrandedPermission[],
refetchPermissions: jest.fn(),
};
// Builds a full UseAuthZResult so `isGranted` stays consistent with `permissions`
// rather than being a stub that could drift from it.
function authZResult(overrides: Partial<UseAuthZResult> = {}): UseAuthZResult {
const base: UseAuthZResult = {
isLoading: false,
isFetching: false,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [] as BrandedPermission[],
isGranted: (): boolean => false,
refetchPermissions: jest.fn(),
...overrides,
};
return {
...base,
isGranted: (permission: BrandedPermission): boolean =>
base.permissions?.[permission]?.isGranted === true,
};
}
const TestButton = (
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -42,10 +54,11 @@ const attachRolePerm = buildPermission(
describe('AuthZTooltip — single check', () => {
it('renders child unchanged when permission is granted', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: true } },
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: { [createPerm]: { isGranted: true } },
}),
);
render(
<AuthZTooltip checks={[createPerm]}>
@@ -57,10 +70,11 @@ describe('AuthZTooltip — single check', () => {
});
it('disables child when permission is denied', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: false } },
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: { [createPerm]: { isGranted: false } },
}),
);
render(
<AuthZTooltip checks={[createPerm]}>
@@ -72,10 +86,11 @@ describe('AuthZTooltip — single check', () => {
});
it('shows formatted permission message in tooltip when denied', async () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: false } },
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: { [createPerm]: { isGranted: false } },
}),
);
render(
<AuthZTooltip checks={[createPerm]}>
@@ -95,7 +110,7 @@ describe('AuthZTooltip — single check', () => {
});
it('disables child while loading', () => {
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
mockUseAuthZ.mockReturnValue(authZResult({ isLoading: true }));
render(
<AuthZTooltip checks={[createPerm]}>
@@ -110,13 +125,14 @@ describe('AuthZTooltip — single check', () => {
describe('AuthZTooltip — multi-check (checks array)', () => {
it('renders child enabled when all checks are granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: true },
[attachRolePerm]: { isGranted: true },
},
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: {
[sa]: { isGranted: true },
[attachRolePerm]: { isGranted: true },
},
}),
);
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
@@ -129,13 +145,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
it('disables child when first check is denied, second granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: true },
},
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: true },
},
}),
);
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
@@ -148,13 +165,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
}),
);
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
@@ -173,13 +191,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
it('shows multiple formatted permissions in tooltip when both denied', async () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
});
mockUseAuthZ.mockReturnValue(
authZResult({
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
}),
);
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>

View File

@@ -1,10 +0,0 @@
.errorContent {
background: var(--callout-error-background) !important;
border-color: var(--callout-error-border) !important;
backdrop-filter: blur(15px);
border-radius: 4px !important;
color: var(--foreground) !important;
font-style: normal;
font-weight: 400;
white-space: nowrap;
}

View File

@@ -1,4 +1,12 @@
import { cloneElement, CSSProperties, ReactElement, useMemo } from 'react';
import {
cloneElement,
CSSProperties,
ReactElement,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {
TooltipContent,
TooltipProvider,
@@ -9,7 +17,9 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
import { useAppContext } from 'providers/App/App';
import styles from './AuthZTooltip.module.scss';
import cx from 'classnames';
import styles from '../tooltipContent.module.scss';
const DISABLED_STYLE: CSSProperties = {
pointerEvents: 'all',
@@ -22,7 +32,23 @@ interface AuthZTooltipProps {
checks: BrandedPermission[];
children: ReactElement;
enabled?: boolean;
/**
* Replace the standard denial wording. Prefer the default — it names the exact
* scopes — and reach for this only when a surface genuinely needs its own.
*/
tooltipMessage?: string;
/**
* A block the consumer already knows about that is not a permission — a lock,
* an immutable resource, a forced read-only mount.
*
* It takes precedence over the checks, which are skipped entirely: the control
* is unavailable either way, so running them would only cost a request. Set it
* only when the non-permission block is the real obstacle, so a missing
* permission still surfaces its own message.
*/
disabledTooltip?: string;
/** Which side of the control to render against. Defaults to the top. */
side?: 'top' | 'bottom' | 'left' | 'right';
/**
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
* otherwise the tooltip will not have the correct z-index
@@ -47,10 +73,17 @@ function AuthZTooltip({
children,
enabled = true,
tooltipMessage,
disabledTooltip,
side,
withPortal,
}: AuthZTooltipProps): JSX.Element {
const { user } = useAppContext();
const shouldCheck = enabled && checks.length > 0;
const isPointerOverRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
// The block the consumer passed is already decisive, so the check is not run.
const isBlocked = !!disabledTooltip;
const shouldCheck = enabled && checks.length > 0 && !isBlocked;
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
@@ -61,6 +94,20 @@ function AuthZTooltip({
return checks.filter((p) => permissions[p]?.isGranted === false);
}, [checks, permissions]);
/**
* Radix closes the tooltip on pointerdown and on click, and merges its own
* handlers after the trigger's regardless of `preventDefault`, so the close is
* filtered here. Clicking a dead control does nothing, which is exactly when
* its reason is still wanted, so a close is ignored while the pointer remains
* on it. Everything else stays Radix's to decide.
*/
const handleOpenChange = useCallback((next: boolean): void => {
if (!next && isPointerOverRef.current) {
return;
}
setIsOpen(next);
}, []);
if (shouldCheck && isLoading) {
return cloneElement(children, {
disabled: true,
@@ -71,7 +118,7 @@ function AuthZTooltip({
});
}
if (!shouldCheck || deniedPermissions.length === 0) {
if (!isBlocked && (!shouldCheck || deniedPermissions.length === 0)) {
return children;
}
@@ -79,7 +126,7 @@ function AuthZTooltip({
return (
<TooltipProvider>
<TooltipRoot>
<TooltipRoot open={isOpen} onOpenChange={handleOpenChange}>
<TooltipTrigger asChild testId={childTestId}>
{cloneElement(children, {
disabled: true,
@@ -87,11 +134,31 @@ function AuthZTooltip({
onClick: noOp,
onMouseDown: noOp,
onPointerDown: noOp,
'data-denied-permissions': deniedPermissions.join(','),
onPointerEnter: (): void => {
isPointerOverRef.current = true;
},
onPointerLeave: (): void => {
isPointerOverRef.current = false;
},
...(isBlocked
? {}
: { 'data-denied-permissions': deniedPermissions.join(',') }),
})}
</TooltipTrigger>
<TooltipContent className={styles.errorContent} withPortal={withPortal}>
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
<TooltipContent
side={side}
// A denial has no arrow; a state the user can act on is not an error
// and reads as a normal tooltip.
arrow={isBlocked}
className={cx(
isBlocked ? styles.blockedContent : styles.errorContent,
styles.aboveOverlay,
)}
withPortal={withPortal}
>
{isBlocked
? disabledTooltip
: formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>

View File

@@ -0,0 +1,42 @@
// Shared presentation for every "you can't use this, here's why" tooltip, so the
// explanation looks the same wherever it surfaces.
//
// These drive the bubble AND the arrow: @signozhq/ui paints both from
// --tooltip-background / --tooltip-border-color, so setting the variables keeps
// them in step. Overriding `background`/`border-color` directly styles only the
// bubble and leaves the arrow on the default fill.
.errorContent {
--tooltip-background: var(--callout-error-background);
--tooltip-border-color: var(--callout-error-border);
--tooltip-foreground: var(--foreground);
backdrop-filter: blur(15px);
border-radius: 4px;
font-style: normal;
font-weight: 400;
// Reasons naming two permissions run long; wrap rather than stretch the
// bubble across the viewport.
max-width: 260px;
white-space: normal;
}
// A block the user can act on themselves — a lock, an integration-owned or
// legacy dashboard — reads as state, not as an access error.
.blockedContent {
--tooltip-background: var(--l2-background);
--tooltip-border-color: var(--l2-border);
--tooltip-foreground: var(--l1-foreground);
backdrop-filter: blur(15px);
border-radius: 4px;
font-style: normal;
font-weight: 400;
max-width: 260px;
white-space: normal;
}
.aboveOverlay {
// Lift above the dropdown menu (z 50) and the antd Drawer (z 1000) so the
// tooltip is never clipped behind them. The arrow reads this too.
--tooltip-z-index: 1100;
}

View File

@@ -6,6 +6,7 @@ import {
} from 'react-router-dom';
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
export type RouterContext = {
/**
@@ -45,6 +46,18 @@ export type WithAuthZOptions<P> = {
checks:
| BrandedPermission[]
| ((props: P, router: RouterContext) => BrandedPermission[]);
/**
* Extra permissions to fetch in the same batch as `checks`, without gating on
* them. `useAuthZ` coalesces everything requested in the same tick into one
* request and caches per permission, so a component below the guard that
* needs these resolves from cache instead of firing a second round trip and
* flipping its controls once it lands.
*
* Never affects whether the content renders — a denial here is ignored.
*/
preloadChecks?:
| BrandedPermission[]
| ((props: P, router: RouterContext) => BrandedPermission[]);
fallback?: AuthZGuardProps['fallback'];
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
@@ -86,12 +99,21 @@ export function createAuthZHOC<P extends object>(
Component: ComponentType<P>,
opts: WithAuthZOptions<P>,
): ComponentType<P> {
const { checks, ...guardProps } = opts;
const { checks, preloadChecks, ...guardProps } = opts;
function Wrapped(props: P): ReactElement | null {
const router = useRouterContext();
const resolvedChecks =
typeof checks === 'function' ? checks(props, router) : checks;
const resolvedPreload =
typeof preloadChecks === 'function'
? preloadChecks(props, router)
: preloadChecks;
// Requested here rather than through the guard: `useAuthZ` coalesces
// everything asked for in the same tick into one request, so this rides
// along with the guard's own checks without being able to gate rendering.
useAuthZ(resolvedPreload ?? [], { enabled: !!resolvedPreload?.length });
return (
<Guard checks={resolvedChecks} {...guardProps}>

View File

@@ -3,6 +3,11 @@ export default {
status: 'success',
data: {
resources: [
{
kind: 'dashboard',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'factor-api-key',
type: 'metaresource',

View File

@@ -0,0 +1,18 @@
import { buildPermission } from '../utils';
import type { BrandedPermission } from '../types';
// Collection-level — wildcard selector required for correct response key matching.
// `list` also covers pin/unpin and saved-view CRUD, which the backend gates on it.
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
export const DashboardCreatePermission = buildPermission(
'create',
'dashboard:*',
);
// Resource-level — require a specific dashboard id
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `dashboard:${id}`);
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `dashboard:${id}`);
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
buildPermission('delete', `dashboard:${id}`);

View File

@@ -97,5 +97,9 @@ export type UseAuthZResult = {
* Checks that resolved as not granted (empty while loading/error).
*/
deniedPermissions: BrandedPermission[];
/**
* Use this to check if a specific permission is granted, false while loading or on error.
*/
isGranted: (permission: BrandedPermission) => boolean;
refetchPermissions: () => void;
};

View File

@@ -240,6 +240,12 @@ export function useAuthZ(
return permissions.every((check) => data[check]?.isGranted === true);
}, [permissions, data, isLoading, error]);
const isGranted = useCallback(
(permission: BrandedPermission): boolean =>
data?.[permission]?.isGranted === true,
[data],
);
const deniedPermissions = useMemo(() => {
if (!data) {
return [];
@@ -254,6 +260,7 @@ export function useAuthZ(
permissions: data ?? null,
allowed,
deniedPermissions,
isGranted,
refetchPermissions,
};
}

View File

@@ -170,6 +170,7 @@ export function mockUseAuthZGrantAll(
) as UseAuthZResult['permissions'],
allowed: true,
deniedPermissions: [],
isGranted: (): boolean => true,
refetchPermissions: jest.fn(),
};
}
@@ -187,6 +188,7 @@ export function mockUseAuthZDenyAll(
) as UseAuthZResult['permissions'],
allowed: false,
deniedPermissions: permissions,
isGranted: (): boolean => false,
refetchPermissions: jest.fn(),
};
}
@@ -213,6 +215,8 @@ export function mockUseAuthZGrantByPrefix(
) as UseAuthZResult['permissions'],
allowed: denied.length === 0,
deniedPermissions: denied,
isGranted: (permission): boolean =>
prefixes.some((prefix) => permission.startsWith(prefix)),
refetchPermissions: jest.fn(),
};
};

View File

@@ -1,4 +1,5 @@
import {
type ReactElement,
type ReactNode,
useCallback,
useEffect,
@@ -29,29 +30,31 @@ import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/service
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useAppContext } from 'providers/App/App';
import { useDashboardCollectionPermissions } from 'hooks/dashboards/useDashboardCollectionPermissions';
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import MenuActionItem from '../../components/MenuActionItem/MenuActionItem';
import DashboardSettings from '../../DashboardSettings';
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
import SettingsDrawer from '../SettingsDrawer';
import menuStyles from '../../components/MenuActionItem/MenuActionItem.module.scss';
import styles from './DashboardActions.module.scss';
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
interface DashboardActionsProps {
title: string;
dashboard: DashboardtypesGettableDashboardV2DTO;
handle: FullScreenHandle;
isDashboardLocked: boolean;
isAuthor: boolean;
onAddPanel: () => void;
onLockToggle: () => void;
onOpenRename: () => void;
@@ -62,19 +65,37 @@ function DashboardActions({
dashboard,
handle,
isDashboardLocked,
isAuthor,
onAddPanel,
onLockToggle,
onOpenRename,
}: DashboardActionsProps): JSX.Element {
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const isEditable = useDashboardStore((s) => s.isEditable);
const {
isLocked,
isEditable,
editChecks,
editDisabledTooltip,
deleteChecks,
deleteDisabledTooltip,
canDeleteDashboard,
canReadDashboard,
} = useDashboardEditContext();
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const { canCreate } = useDashboardCollectionPermissions();
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
useDashboardLockPermission({
dashboardId: dashboard.id,
source: dashboard.source,
});
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
const cloneChecks = useMemo(
() => [...editChecks.slice(0, 1), DashboardCreatePermission],
[editChecks],
);
const cloneDenied = !canCreate || !canReadDashboard;
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
@@ -157,101 +178,110 @@ function DashboardActions({
void handle.enter();
}, [dashboard.id, handle]);
// Shown only to edit-permitted users, so the only disabled reason is the lock.
const editLabel = useCallback(
(text: string): ReactNode =>
isLocked ? (
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
{text}
</DisabledMenuItemLabel>
) : (
text
),
[isLocked],
// Unavailable items stay in the menu, carrying the reason.
// The row carries icon, label and reason; the item keeps `disabled`/`onClick`.
const row = useCallback(
(
text: string,
icon: ReactElement,
checks: BrandedPermission[],
opts: { disabledTooltip?: string; destructive?: boolean } = {},
): ReactNode => (
<MenuActionItem
label={text}
icon={icon}
checks={checks}
disabledTooltip={opts.disabledTooltip}
destructive={opts.destructive}
/>
),
[],
);
const menuItems = useMemo<MenuItem[]>(() => {
const dashboardGroup: MenuItem[] = [];
if (canEditDashboard) {
dashboardGroup.push({
const dashboardGroup: MenuItem[] = [
{
key: 'rename',
label: editLabel('Rename'),
icon: <PenLine size={14} />,
disabled: isLocked,
label: row('Rename', <PenLine size={14} />, editChecks, {
disabledTooltip: editDisabledTooltip,
}),
disabled: !isEditable,
onClick: onOpenRename,
});
},
// Clone creates a new dashboard, so it's not lock-gated.
dashboardGroup.push({
{
key: 'clone',
label: 'Clone dashboard',
icon: <Copy size={14} />,
disabled: isCloning,
label: row('Clone dashboard', <Copy size={14} />, cloneChecks),
disabled: isCloning || cloneDenied,
onClick: (): void => void handleClone(),
});
}
if (canEditDashboard && (isAuthor || user.role === USER_ROLES.ADMIN)) {
dashboardGroup.push({
},
{
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
icon: <LockKeyhole size={14} />,
disabled: dashboard.createdBy === 'integration',
label: row(
isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
<LockKeyhole size={14} />,
editChecks,
{ disabledTooltip: lockDisabledTooltip },
),
disabled: !canToggleLock,
onClick: onLockToggle,
});
}
dashboardGroup.push({
key: 'fullscreen',
label: 'Full screen',
icon: <Fullscreen size={14} />,
onClick: handleEnterFullScreen,
});
},
{
key: 'fullscreen',
label: row('Full screen', <Fullscreen size={14} />, []),
onClick: handleEnterFullScreen,
},
];
const items: MenuItem[] = [
return [
{
type: 'group',
key: 'group-dashboard',
label: 'Dashboard',
children: dashboardGroup,
},
];
// Omit the whole Layout group (header included) in view mode.
if (canEditDashboard) {
items.push({
{
type: 'group',
key: 'group-layout',
label: 'Layout',
children: [
{
key: 'new-section',
label: editLabel('New section'),
icon: <SquareStack size={14} />,
disabled: isLocked,
label: row('New section', <SquareStack size={14} />, editChecks, {
disabledTooltip: editDisabledTooltip,
}),
disabled: !isEditable,
onClick: (): void => setIsNewSectionOpen(true),
},
],
});
items.push(
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: editLabel('Delete dashboard'),
icon: <Trash2 size={14} />,
danger: true,
disabled: isLocked,
onClick: confirmDeleteDashboard,
},
);
}
return items;
},
{ type: 'divider', key: 'divider-danger' },
{
key: 'delete',
label: row('Delete dashboard', <Trash2 size={14} />, deleteChecks, {
disabledTooltip: deleteDisabledTooltip,
destructive: true,
}),
// Independent of read/update, but a locked dashboard can't be removed.
disabled: isLocked || !canDeleteDashboard,
onClick: confirmDeleteDashboard,
},
];
}, [
editLabel,
canEditDashboard,
row,
isEditable,
isLocked,
editChecks,
editDisabledTooltip,
deleteChecks,
deleteDisabledTooltip,
canDeleteDashboard,
cloneChecks,
cloneDenied,
isCloning,
isAuthor,
user.role,
canToggleLock,
lockDisabledTooltip,
isDashboardLocked,
dashboard.createdBy,
onOpenRename,
handleClone,
onLockToggle,
@@ -261,7 +291,10 @@ function DashboardActions({
return (
<div className={styles.dashboardActionsContainer}>
<DropdownMenuSimple menu={{ items: menuItems }}>
<DropdownMenuSimple
menu={{ items: menuItems }}
className={menuStyles.menuContent}
>
<Button
variant="solid"
color="secondary"
@@ -273,38 +306,31 @@ function DashboardActions({
Actions
</Button>
</DropdownMenuSimple>
{canEditDashboard && (
<>
<DisabledControlTooltip
reason={DASHBOARD_LOCKED_REASON}
disabled={isLocked}
>
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={isLocked}
onClick={handleOpenSettings}
size="md"
>
Configure
</Button>
</DisabledControlTooltip>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
</>
)}
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
variant="solid"
color="secondary"
className={styles.toolbarButton}
prefix={<Configure size="md" />}
testId="show-drawer"
disabled={!isEditable}
onClick={handleOpenSettings}
size="md"
>
Configure
</Button>
</AuthZTooltip>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
destroyOnClose
onClose={(): void => {
setIsSettingsDrawerOpen(false);
clearSettingsRequest();
}}
>
<DashboardSettings dashboard={dashboard} />
</SettingsDrawer>
<Button
variant="solid"
color="secondary"
@@ -316,24 +342,19 @@ function DashboardActions({
>
JSON
</Button>
{canEditDashboard && (
<DisabledControlTooltip
reason={DASHBOARD_LOCKED_REASON}
disabled={isLocked}
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={!isEditable}
size="md"
>
<Button
variant="solid"
color="primary"
onClick={onAddPanel}
prefix={<Plus size="md" />}
testId="add-panel-header"
disabled={isLocked}
size="md"
>
New Panel
</Button>
</DisabledControlTooltip>
)}
New Panel
</Button>
</AuthZTooltip>
<JsonEditorDrawer
dashboard={dashboard}
isOpen={isJsonEditorOpen}

View File

@@ -0,0 +1,189 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
DashboardListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import DashboardActions from '../DashboardActions';
const DASHBOARD_ID = 'dash-1';
const dashboard = {
id: DASHBOARD_ID,
createdBy: 'someone-else@signoz.io',
locked: false,
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
// Composition is what's under test here; the derivation has its own suite.
const mockEditContext = {
isEditable: true,
editChecks: [],
areOtherPermissionsLoading: false,
deleteChecks: [],
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
};
function setEditContextMock(next: Partial<typeof mockEditContext>): void {
Object.assign(mockEditContext, {
isEditable: true,
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
...next,
});
}
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
}),
);
// The dropdown trigger's testId is swallowed by Radix's asChild clone.
function openActionsMenu(): Promise<void> {
return userEvent.click(screen.getByRole('button', { name: /Actions/ }));
}
function renderActions(): ReturnType<typeof render> {
return render(
<DashboardActions
title="D"
dashboard={dashboard}
handle={
{
active: false,
enter: jest.fn(),
exit: jest.fn(),
node: { current: null },
} as never
}
isDashboardLocked={false}
onAddPanel={jest.fn()}
onLockToggle={jest.fn()}
onOpenRename={jest.fn()}
/>,
);
}
describe('DashboardActions - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('permission denied', () => {
// These controls used to be removed from the DOM entirely.
it('keeps the toolbar buttons visible and disabled', async () => {
server.use(setupAuthzDenyAll());
setEditContextMock({
isEditable: false,
canEditDashboard: false,
editDisabledTooltip: 'no permission',
});
renderActions();
await waitFor(() => {
expect(screen.getByTestId('show-drawer')).toBeDisabled();
});
expect(screen.getByTestId('add-panel-header')).toBeDisabled();
// JSON stays available — it's a read-only inspect.
expect(screen.getByTestId('edit-json')).toBeEnabled();
});
it('keeps the menu items present and disabled', async () => {
server.use(setupAuthzDenyAll());
setEditContextMock({
isEditable: false,
canEditDashboard: false,
canDeleteDashboard: false,
editDisabledTooltip: 'no permission',
deleteDisabledTooltip: 'no permission',
});
renderActions();
await openActionsMenu();
await expect(screen.findByText('Rename')).resolves.toBeInTheDocument();
expect(screen.getByText('New section')).toBeInTheDocument();
expect(screen.getByText('Delete dashboard')).toBeInTheDocument();
expect(screen.getByText('Clone dashboard')).toBeInTheDocument();
// Full screen never depended on permission.
expect(screen.getByText('Full screen')).toBeInTheDocument();
});
});
describe('partial permissions', () => {
// Delete is independent of read/update (authz guide rule 3).
it('enables delete for a user who can only delete', async () => {
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
setEditContextMock({
isEditable: false,
canEditDashboard: false,
editDisabledTooltip: 'no permission',
});
renderActions();
await waitFor(() => {
expect(screen.getByTestId('show-drawer')).toBeDisabled();
});
await openActionsMenu();
await expect(
screen.findByText('Delete dashboard'),
).resolves.toBeInTheDocument();
});
it('disables clone when create is denied but edit is allowed', async () => {
server.use(
setupAuthzAllow(
buildDashboardReadPermission(DASHBOARD_ID),
buildDashboardUpdatePermission(DASHBOARD_ID),
DashboardListPermission,
IsAdminPermission,
),
);
setEditContextMock({});
renderActions();
await waitFor(() => {
expect(screen.getByTestId('show-drawer')).toBeEnabled();
});
await openActionsMenu();
await expect(
screen.findByText('Clone dashboard'),
).resolves.toBeInTheDocument();
});
});
describe('permission granted', () => {
it('enables the toolbar for a full-rights user', async () => {
server.use(setupAuthzAdmin());
setEditContextMock({});
renderActions();
await waitFor(() => {
expect(screen.getByTestId('show-drawer')).toBeEnabled();
});
expect(screen.getByTestId('add-panel-header')).toBeEnabled();
});
});
});

View File

@@ -25,6 +25,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import TagsOverflowTooltip from './TagsOverflowTooltip';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
@@ -43,6 +44,8 @@ interface DashboardInfoProps {
showLockToggle: boolean;
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
onToggleLock?: () => void;
/** Why the toggle is unavailable, when onToggleLock is absent. */
lockDisabledTooltip?: string;
isEditing: boolean;
draft: string;
onDraftChange: (value: string) => void;
@@ -61,6 +64,7 @@ function DashboardInfo({
isDashboardLocked,
showLockToggle,
onToggleLock,
lockDisabledTooltip,
isEditing,
draft,
onDraftChange,
@@ -68,7 +72,7 @@ function DashboardInfo({
onCommit,
onCancel,
}: DashboardInfoProps): JSX.Element {
const canEdit = useDashboardStore((s) => s.isEditable);
const { isEditable: canEdit } = useDashboardEditContext();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const hasTags = tags.length > 0;
@@ -77,11 +81,15 @@ function DashboardInfo({
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
// Without a toggle, say why it can't be toggled rather than only restating the
// lock state.
let lockTooltip: string;
if (onToggleLock) {
lockTooltip = isDashboardLocked
? 'Locked — click to unlock'
: 'Unlocked — click to lock';
} else if (lockDisabledTooltip) {
lockTooltip = lockDisabledTooltip;
} else {
lockTooltip = isDashboardLocked
? 'This dashboard is locked'

View File

@@ -16,8 +16,8 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import { useDashboardStore } from '../../store/useDashboardStore';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
interface JsonEditorDrawerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -30,10 +30,13 @@ function JsonEditorDrawer({
isOpen,
onClose,
}: JsonEditorDrawerProps): JSX.Element {
const {
isEditable,
editDisabledTooltip: readOnlyTooltip,
editChecks: readOnlyChecks,
} = useDashboardEditContext();
const [, copyToClipboard] = useCopyToClipboard();
const isEditable = useDashboardStore((s) => s.isEditable);
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
// Inspect-only when not editable: Apply/Format/Reset disabled.
const readOnly = !isEditable;
@@ -175,7 +178,10 @@ function JsonEditorDrawer({
>
Cancel
</Button>
<DisabledControlTooltip reason={readOnlyReason} disabled={readOnly}>
<AuthZTooltip
checks={readOnlyChecks}
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
>
<Button
variant="solid"
color="primary"
@@ -186,7 +192,7 @@ function JsonEditorDrawer({
>
Apply changes
</Button>
</DisabledControlTooltip>
</AuthZTooltip>
</div>
</div>
}

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from 'tests/test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
@@ -10,8 +10,21 @@ jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
// Editable by default so the drawer renders in its editable (non-read-only) mode.
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
selector: (s: {
isEditable: boolean;
editChecks: unknown[];
deleteChecks: unknown[];
areOtherPermissionsLoading: boolean;
editDisabledTooltip: string;
}) => unknown,
): unknown =>
selector({
isEditable: true,
editChecks: [],
areOtherPermissionsLoading: false,
deleteChecks: [],
editDisabledTooltip: '',
}),
}));
jest.mock('@monaco-editor/react', () => ({
@@ -65,6 +78,26 @@ function hookValue(
} as ReturnType<typeof useJsonEditor>;
}
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
// about what the UI does with a given edit context, so control it directly.
const mockEditContext = {
isEditable: true,
editChecks: [],
deleteChecks: [],
areOtherPermissionsLoading: false,
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
};
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
}),
);
describe('JsonEditorDrawer', () => {
beforeEach(() => jest.clearAllMocks());
@@ -95,7 +128,7 @@ describe('JsonEditorDrawer', () => {
mockUseJsonEditor.mockReturnValue(
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
);
const { rerender } = render(
const { unmount } = render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,
@@ -104,8 +137,11 @@ describe('JsonEditorDrawer', () => {
'2 panels not present in layout',
);
// Mounted fresh rather than re-rendered: the provider wrapper holds the
// first tree, so a rerender does not pick up the new hook value.
unmount();
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
rerender(
render(
<TooltipProvider>
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
</TooltipProvider>,

View File

@@ -1,25 +1,18 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
getGetDashboardV2QueryKey,
lockDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
GetDashboardV2200,
} from 'api/generated/services/sigNoz.schemas';
import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useAppContext } from 'providers/App/App';
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
import { useToggleDashboardLock } from 'hooks/dashboards/useToggleDashboardLock';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { useCreatePanel } from '../hooks/useCreatePanel';
@@ -42,7 +35,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { dashboard, handle } = props;
const id = dashboard.id;
const queryClient = useQueryClient();
// Session-local lock state: the toggle appears once locked and persists for the page.
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
@@ -64,7 +56,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
[dashboard.tags],
);
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
@@ -75,59 +66,51 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
targetLayoutIndex,
} = useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
// dashboards are never toggleable.
const canToggleLock =
(isAuthor || user.role === USER_ROLES.ADMIN) &&
dashboard.createdBy !== 'integration';
// dashboard:update, then the backend's source and creator-or-admin rules.
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
useDashboardLockPermission({
dashboardId: id,
source: dashboard.source,
});
// Public-sharing meta (deduped react-query read); drives the header globe.
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
// Shared with the list's row menu — it owns the API call, the toast and the
// detail-cache patch; the optimistic local state and this page's event stay here.
const lockSource = useRef<'menu' | 'header'>('header');
const { toggleLock } = useToggleDashboardLock({
dashboardId: id,
isLocked: isDashboardLocked,
onSuccess: (locked) => {
void logEvent(DashboardDetailEvents.LockToggled, {
dashboardId: id,
dashboardName: title,
locked,
source: lockSource.current,
});
},
onError: (error) => {
setIsDashboardLocked(isDashboardLocked);
showErrorModal(error);
},
});
const handleLockDashboardToggle = useCallback(
async (source: 'menu' | 'header'): Promise<void> => {
(source: 'menu' | 'header'): void => {
if (!id) {
return;
}
lockSource.current = source;
const next = !isDashboardLocked;
setIsDashboardLocked(next);
if (next) {
setShowLockToggle(true);
}
try {
if (next) {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
} else {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
}
// Patch just the `locked` flag in the cache — a full refetch would reload
// every panel's chart data for a metadata-only change.
const key = getGetDashboardV2QueryKey({ id });
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
if (cached) {
queryClient.setQueryData<GetDashboardV2200>(key, {
...cached,
data: { ...cached.data, locked: next },
});
}
void logEvent(DashboardDetailEvents.LockToggled, {
dashboardId: id,
dashboardName: title,
locked: next,
source,
});
} catch (error) {
setIsDashboardLocked(!next);
showErrorModal(error as APIError);
}
toggleLock();
},
[id, title, isDashboardLocked, queryClient, showErrorModal],
[id, isDashboardLocked, toggleLock],
);
const onNameSave = useCallback(
@@ -184,9 +167,10 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
showLockToggle={showLockToggle}
onToggleLock={
canToggleLock
? (): void => void handleLockDashboardToggle('header')
? (): void => handleLockDashboardToggle('header')
: undefined
}
lockDisabledTooltip={lockDisabledTooltip}
isEditing={isEditing}
draft={draft}
onDraftChange={setDraft}
@@ -199,9 +183,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
dashboard={dashboard}
handle={handle}
isDashboardLocked={isDashboardLocked}
isAuthor={isAuthor}
onAddPanel={onAddPanel}
onLockToggle={(): void => void handleLockDashboardToggle('menu')}
onLockToggle={(): void => handleLockDashboardToggle('menu')}
onOpenRename={startEdit}
/>
</div>

View File

@@ -1,11 +1,19 @@
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import styles from './PublicDashboardActions.module.scss';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
interface PublicDashboardActionsProps {
isPublic: boolean;
disabled: boolean;
/**
* Why publishing is unavailable. Non-empty both disables the buttons and
* explains them, so they cannot be disabled silently.
*/
checks: BrandedPermission[];
/** In-flight config read — transient, and a spinner explains itself. */
isLoading?: boolean;
isPublishing: boolean;
isUpdating: boolean;
isUnpublishing: boolean;
@@ -16,7 +24,8 @@ interface PublicDashboardActionsProps {
function PublicDashboardActions({
isPublic,
disabled,
checks,
isLoading = false,
isPublishing,
isUpdating,
isUnpublishing,
@@ -24,45 +33,53 @@ function PublicDashboardActions({
onUpdate,
onUnpublish,
}: PublicDashboardActionsProps): JSX.Element {
const disabled = isLoading;
return (
<div className={styles.footer}>
{isPublic ? (
<>
<Button
variant="outlined"
color="destructive"
disabled={disabled}
loading={isUnpublishing}
prefix={<Trash size={15} />}
testId="public-dashboard-unpublish"
onClick={onUnpublish}
>
Unpublish Dashboard
</Button>
<AuthZTooltip checks={checks}>
<Button
variant="outlined"
color="destructive"
disabled={disabled}
loading={isUnpublishing}
prefix={<Trash size={15} />}
testId="public-dashboard-unpublish"
onClick={onUnpublish}
>
Unpublish Dashboard
</Button>
</AuthZTooltip>
<AuthZTooltip checks={checks}>
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isUpdating}
prefix={<RefreshCw size={15} />}
testId="public-dashboard-update"
onClick={onUpdate}
>
Update Dashboard
</Button>
</AuthZTooltip>
</>
) : (
<AuthZTooltip checks={checks}>
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isUpdating}
prefix={<RefreshCw size={15} />}
testId="public-dashboard-update"
onClick={onUpdate}
loading={isPublishing}
prefix={<Globe size={15} />}
testId="public-dashboard-publish"
onClick={onPublish}
>
Update Dashboard
Publish Dashboard
</Button>
</>
) : (
<Button
variant="solid"
color="primary"
disabled={disabled}
loading={isPublishing}
prefix={<Globe size={15} />}
testId="public-dashboard-publish"
onClick={onPublish}
>
Publish Dashboard
</Button>
</AuthZTooltip>
)}
</div>
);

View File

@@ -0,0 +1,61 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { buildDashboardUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import PublicDashboardSettings from '../index';
const DASHBOARD_ID = 'dash-1';
const PUBLIC_URL = `http://localhost/api/v1/dashboards/${DASHBOARD_ID}/public`;
const dashboard = {
id: DASHBOARD_ID,
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
describe('PublicDashboard - AuthZ', () => {
beforeEach(() => {
// Not published yet — the tab offers Publish.
server.use(
rest.get(PUBLIC_URL, (_req, res, ctx) =>
res(ctx.status(404), ctx.json({ status: 'error', error: {} })),
),
);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
// The backend gates the public-config writes on dashboard:update, so a
// licensed editor can publish — this used to be admin-only in the UI.
it('lets a non-admin holding update publish', async () => {
server.use(setupAuthzAdmin());
render(<PublicDashboardSettings dashboard={dashboard} />, undefined, {
role: 'EDITOR',
});
await waitFor(() => {
expect(screen.getByTestId('public-dashboard-publish')).toBeEnabled();
});
});
it('disables publishing when update is denied', async () => {
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
render(<PublicDashboardSettings dashboard={dashboard} />, undefined, {
role: 'ADMIN',
});
await waitFor(() => {
expect(screen.getByTestId('public-dashboard-publish')).toBeDisabled();
});
});
});

View File

@@ -5,6 +5,7 @@ import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
import { usePublicDashboard } from './usePublicDashboard';
import styles from './PublicDashboard.module.scss';
@@ -17,7 +18,8 @@ function PublicDashboardSettings({
}: PublicDashboardSettingsProps): JSX.Element {
const {
isPublic,
isAdmin,
canManage,
publishChecks,
isLoading,
isPublishing,
isUpdating,
@@ -34,7 +36,7 @@ function PublicDashboardSettings({
onOpenUrl,
} = usePublicDashboard(dashboard.id);
const controlsDisabled = isLoading || !isAdmin;
const controlsDisabled = isLoading || !canManage;
return (
<div className={styles.publishTab}>
@@ -61,7 +63,8 @@ function PublicDashboardSettings({
<PublicDashboardActions
isPublic={isPublic}
disabled={controlsDisabled}
checks={publishChecks}
isLoading={isLoading}
isPublishing={isPublishing}
isUpdating={isUpdating}
isUnpublishing={isUnpublishing}

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { useDashboardPermissions } from 'hooks/dashboards/useDashboardPermissions';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { toast } from '@signozhq/ui/sonner';
import logEvent from 'api/common/logEvent';
import {
@@ -11,10 +13,8 @@ import {
} from 'api/generated/services/dashboard';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
@@ -22,7 +22,10 @@ import { usePublicDashboardMeta } from './usePublicDashboardMeta';
export interface UsePublicDashboardReturn {
isPublic: boolean;
isAdmin: boolean;
/** read + update on this dashboard — publishing is a dashboard update. */
canManage: boolean;
/** `[read, update]` — publishing changes the dashboard. */
publishChecks: BrandedPermission[];
isLoading: boolean;
isPublishing: boolean;
isUpdating: boolean;
@@ -49,8 +52,10 @@ export function usePublicDashboard(
): UsePublicDashboardReturn {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { user } = useAppContext();
const isAdmin = user?.role === USER_ROLES.ADMIN;
// The backend gates the public-config writes on dashboard:update, not on the
// admin role, so a licensed editor can publish.
const { canEdit: canManage, editChecks: publishChecks } =
useDashboardPermissions(dashboardId);
const [, copyToClipboard] = useCopyToClipboard();
const [timeRangeEnabled, setTimeRangeEnabled] = useState<boolean>(true);
@@ -196,7 +201,8 @@ export function usePublicDashboard(
return {
isPublic,
isAdmin,
canManage,
publishChecks,
isLoading,
isPublishing,
isUpdating,

View File

@@ -1,26 +1,28 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
const AddVariableButton = ({
isEditable,
checks,
disabledTooltip,
setIsEditing,
}: {
isEditable: boolean;
checks: BrandedPermission[];
disabledTooltip?: string;
setIsEditing: (state: { type: 'new' }) => void;
}): JSX.Element => {
return (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
size="md"
onClick={(): void => setIsEditing({ type: 'new' })}
testId="add-variable"
disabled={!isEditable}
>
Add variable
</Button>
);
};
}): JSX.Element => (
<AuthZButton
checks={checks}
disabledTooltip={disabledTooltip}
variant="solid"
color="primary"
prefix={<Plus size={14} />}
size="md"
onClick={(): void => setIsEditing({ type: 'new' })}
testId="add-variable"
>
Add variable
</AuthZButton>
);
export default AddVariableButton;

View File

@@ -2,12 +2,15 @@ import { Typography } from '@signozhq/ui/typography';
import AddVariableButton from '../AddVariableButton';
import { EditingState } from '../../types';
import styles from './NoVariables.module.scss';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
const NoVariablesCard = ({
isEditable,
checks,
disabledTooltip,
setIsEditing,
}: {
isEditable: boolean;
checks: BrandedPermission[];
disabledTooltip?: string;
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
}): JSX.Element => {
return (
@@ -20,7 +23,11 @@ const NoVariablesCard = ({
Create a variable to parameterize your panel queries.
</Typography.Text>
</div>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
<AddVariableButton
checks={checks}
disabledTooltip={disabledTooltip}
setIsEditing={setIsEditing}
/>
</div>
);
};

View File

@@ -20,13 +20,15 @@ import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
import { EditingState } from './types';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
// The drawer destroys on close, so reading this once on mount is enough to
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
const openAddOnMount = useDashboardStore(
@@ -127,7 +129,11 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
return (
<div className={cx(styles.container, settingsStyles.settingsCard)}>
{variables.length === 0 ? (
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
<NoVariablesCard
checks={editChecks}
disabledTooltip={editDisabledTooltip}
setIsEditing={setIsEditing}
/>
) : (
<>
<VariablesList
@@ -143,7 +149,11 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
appliedToAllNames={appliedToAllNames}
/>
<div className={styles.footer}>
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
<AddVariableButton
checks={editChecks}
disabledTooltip={editDisabledTooltip}
setIsEditing={setIsEditing}
/>
</div>
</>
)}

View File

@@ -0,0 +1,75 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import DashboardSettings from '../index';
const DASHBOARD_ID = 'dash-1';
const dashboard = {
id: DASHBOARD_ID,
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
let isCloudUser = true;
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): {
isCloudUser: boolean;
isEnterpriseSelfHostedUser: boolean;
} => ({ isCloudUser, isEnterpriseSelfHostedUser: false }),
}));
describe('DashboardSettings - AuthZ', () => {
beforeEach(() => {
isCloudUser = true;
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders a trigger per tab', async () => {
server.use(setupAuthzAdmin());
render(<DashboardSettings dashboard={dashboard} />);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
});
expect(screen.getByRole('tab', { name: /Variables/ })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /Publish/ })).toBeInTheDocument();
});
// The triggers used to be rendered from the TabKeys enum rather than the
// items list, so Publish appeared on OSS and landed on an empty body.
it('omits the Publish tab when public dashboards are unavailable', async () => {
isCloudUser = false;
server.use(setupAuthzAdmin());
render(<DashboardSettings dashboard={dashboard} />);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
});
expect(
screen.queryByRole('tab', { name: /Publish/ }),
).not.toBeInTheDocument();
});
// Reading the publish config only needs read; the writes inside gate on update.
it('keeps the Publish tab reachable for a non-admin', async () => {
server.use(setupAuthzDenyAll());
render(<DashboardSettings dashboard={dashboard} />);
await waitFor(() => {
expect(screen.getByRole('tab', { name: /Publish/ })).toBeInTheDocument();
});
expect(screen.getByRole('tab', { name: /Publish/ })).toBeEnabled();
});
});

View File

@@ -13,9 +13,7 @@ import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/service
import Overview from './Overview';
import PublicDashboardSettings from './PublicDashboard';
import VariablesSettings from './Variables';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { useDashboardStore } from '../store/useDashboardStore';
import styles from './DashboardSettings.module.scss';
@@ -37,7 +35,6 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
};
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const { user } = useAppContext();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
// Opened once per drawer mount (the drawer destroys on close); a deep-link
// request lands us on the right tab.
@@ -58,27 +55,28 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
},
// Readable by anyone who can open the dashboard; the controls inside
// gate on update.
...(enablePublicDashboard
? [
{
key: TabKeys.PUBLISH,
label: TabKeys.PUBLISH,
children: <PublicDashboardSettings dashboard={dashboard} />,
disabled: user?.role !== USER_ROLES.ADMIN,
},
]
: []),
],
[enablePublicDashboard, dashboard, user?.role],
[enablePublicDashboard, dashboard],
);
return (
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
<TabsList variant="primary">
{Object.values(TabKeys).map((key) => (
<TabsTrigger value={key} key={key}>
{prefixIcons[key]}
{key}
{items.map((item) => (
<TabsTrigger value={item.key} key={item.key} disabled={item.disabled}>
{prefixIcons[item.key as TabKeys]}
{item.label}
</TabsTrigger>
))}
</TabsList>

View File

@@ -8,10 +8,12 @@ import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import styles from './Header.module.scss';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
interface HeaderProps {
/** Unsaved edits exist — shows the "Unsaved Changes" badge and gates the discard confirmation on close (not the Save button). */
@@ -20,7 +22,9 @@ interface HeaderProps {
showSwitchToView?: boolean;
/** Locked/no-permission dashboard — Save is disabled with a reason. */
readOnly?: boolean;
readOnlyReason?: string;
/** Present when saving is unavailable — the Save button explains itself with it. */
readOnlyChecks?: BrandedPermission[];
readOnlyTooltip?: string;
onSave: () => void;
onSwitchToView?: () => void;
onClose: () => void;
@@ -31,7 +35,8 @@ function Header({
isSaving,
showSwitchToView = false,
readOnly = false,
readOnlyReason,
readOnlyChecks = [],
readOnlyTooltip,
onSave,
onSwitchToView,
onClose,
@@ -90,7 +95,10 @@ function Header({
Switch to View Mode
</Button>
)}
<DisabledControlTooltip reason={readOnlyReason ?? ''} disabled={readOnly}>
<AuthZTooltip
checks={readOnlyChecks}
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
>
<Button
variant="solid"
color="primary"
@@ -101,7 +109,7 @@ function Header({
>
Save changes
</Button>
</DisabledControlTooltip>
</AuthZTooltip>
</div>
<DialogWrapper

View File

@@ -1,6 +1,6 @@
import type { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { render, screen } from 'tests/test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
@@ -82,7 +82,11 @@ describe('PanelEditor Header', () => {
it('disables Save only while read-only or saving', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader({ isDirty: true, readOnly: true, readOnlyReason: 'Locked' });
renderHeader({
isDirty: true,
readOnly: true,
readOnlyTooltip: 'Locked',
});
expect(screen.getByTestId('panel-editor-v2-save')).toBeDisabled();
});

View File

@@ -6,6 +6,26 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
// The editor reads its edit context from the loaded dashboard subtree, which
// these composition cases don't stand up; the derivation has its own suite.
const mockEditContext = {
isEditable: true,
editChecks: [],
areOtherPermissionsLoading: false,
deleteChecks: [],
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
};
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
}),
);
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
/**
@@ -172,8 +192,6 @@ function makePanel(
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
isEditable: true,
editDisabledReason: '',
onClose: jest.fn(),
onSaved: jest.fn(),
};

View File

@@ -21,6 +21,7 @@ import {
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
@@ -63,10 +64,6 @@ interface PanelEditorContainerProps {
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
@@ -85,11 +82,14 @@ function PanelEditorContainer({
savedPanel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
@@ -280,7 +280,8 @@ function PanelEditorContainer({
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}

View File

@@ -6,9 +6,11 @@ import dashboardEmojiUrl from '@/assets/Icons/dashboard_emoji.svg';
import landscapeUrl from '@/assets/Icons/landscape.svg';
import { useCreatePanel } from '../../hooks/useCreatePanel';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardStore } from '../../store/useDashboardStore';
import PanelTypeSelectionModal from '../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import styles from './DashboardEmptyState.module.scss';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
interface DashboardEmptyStateProps {
canAddPanel: boolean;
@@ -17,9 +19,10 @@ interface DashboardEmptyStateProps {
function DashboardEmptyState({
canAddPanel,
}: DashboardEmptyStateProps): JSX.Element {
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isEditable = useDashboardStore((s) => s.isEditable);
const requestSettings = useDashboardStore((s) => s.requestSettings);
return (
@@ -48,17 +51,18 @@ function DashboardEmptyState({
</Typography.Text>
</div>
</div>
{isEditable && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
variant="solid"
color="secondary"
prefix={<Configure size="md" />}
disabled={!isEditable}
onClick={(): void => requestSettings({ tab: 'Overview' })}
testId="empty-configure"
>
Configure
</Button>
)}
</AuthZTooltip>
</div>
<div className={styles.step}>
@@ -73,16 +77,17 @@ function DashboardEmptyState({
</Typography.Text>
</div>
</div>
{canAddPanel && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
color="primary"
prefix={<Plus size="md" />}
disabled={!canAddPanel}
onClick={(): void => openPicker()}
testId="add-panel"
>
New Panel
</Button>
)}
</AuthZTooltip>
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import menuStyles from '../../../components/MenuActionItem/MenuActionItem.module.scss';
import styles from './PanelActionsMenu.module.scss';
interface PanelActionsMenuProps {
@@ -43,7 +44,11 @@ function PanelActionsMenu({
return (
<>
<DropdownMenuSimple menu={{ items }} align="end">
<DropdownMenuSimple
menu={{ items }}
align="end"
className={menuStyles.menuContent}
>
<Button
type="button"
variant="ghost"

View File

@@ -1,13 +1,42 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
/** Keys of the disabled items, in order. */
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
// about what the UI does with a given edit context, so control it directly.
const mockEditContext = {
isEditable: true,
editChecks: [],
areOtherPermissionsLoading: false,
deleteChecks: [],
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
};
function setEditContextMock(next: Partial<typeof mockEditContext>): void {
Object.assign(mockEditContext, {
isEditable: true,
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
...next,
});
}
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
}),
);
function disabledKeys(
result: ReturnType<typeof usePanelActionItems>,
): unknown[] {
@@ -64,15 +93,6 @@ jest.mock('../../hooks/useDownloadPanelImage', () => ({
}),
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
jest.mock('providers/App/App', () => ({
useAppContext: (): { user: { role: ROLES } } => ({
user: { role: mockRole },
}),
}));
function section(
layoutIndex: number,
title: string | undefined,
@@ -133,11 +153,10 @@ function itemKeys(result: ReturnType<typeof usePanelActionItems>): unknown[] {
describe('usePanelActionItems', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRole = 'ADMIN';
useDashboardStore.setState({ canEditDashboard: true, isLocked: false });
setEditContextMock({});
});
it('ADMIN on an editable dashboard with a known kind gets the full V1-parity set, divider-separated', () => {
it('an editable dashboard with a known kind gets the full set, divider-separated', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
@@ -155,11 +174,20 @@ describe('usePanelActionItems', () => {
// it's present for every renderable kind.
});
it('AUTHOR loses edit and clone (edit_widget excludes AUTHOR) but keeps the rest', () => {
mockRole = 'AUTHOR';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
// These used to be dropped from the menu, leaving no trace of why.
it('without edit rights keeps the edit actions visible but disabled', () => {
setEditContextMock({
isEditable: false,
canEditDashboard: false,
editDisabledTooltip: 'no permission',
});
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'edit-panel',
'clone-panel',
'divider',
'download',
'create-alert',
@@ -168,34 +196,20 @@ describe('usePanelActionItems', () => {
'divider',
'delete-panel',
]);
});
it('VIEWER keeps only the role-ungated actions (view, download, create-alert)', () => {
mockRole = 'VIEWER';
const { result } = renderHook(() => usePanelActionItems(baseArgs));
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'download',
'create-alert',
]);
});
it('no edit permission (view mode) hides the edit actions entirely', () => {
useDashboardStore.setState({ canEditDashboard: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'download',
'create-alert',
expect(disabledKeys(result.current)).toStrictEqual([
'edit-panel',
'clone-panel',
'move',
'delete-panel',
]);
});
it('locked (edit mode) keeps the edit actions visible but disabled', () => {
useDashboardStore.setState({ canEditDashboard: true, isLocked: true });
setEditContextMock({
isEditable: false,
isLocked: true,
editDisabledTooltip: 'locked',
});
// A locked dashboard mounts panels without layout context (no panelActions).
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),

View File

@@ -1,5 +1,4 @@
import type { PanelActionCapabilities } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { ComponentTypes } from 'utils/permission';
/**
* Every action the panel menu can offer: per-kind gated capabilities (minus
@@ -14,12 +13,6 @@ export type PanelActionId =
| 'delete';
export interface PanelActionMeta {
/**
* Role gate: componentPermission key checked against the current user.
* Absent = available to every role (V1 parity: view, download and
* create-alerts were never role-gated).
*/
permission?: ComponentTypes;
/**
* Kind gate: the PanelActionCapabilities flag this action requires.
* Chrome actions (move/clone/delete) are layout concerns available for
@@ -29,19 +22,19 @@ export interface PanelActionMeta {
}
/**
* Single source of truth for how each panel action is gated, mirroring V1's
* WidgetHeader rules. The third gate — context (editable, target sections) — is
* runtime state resolved in `usePanelActionItems`, not declarable here.
* Single source of truth for the kind gate on each panel action. Whether the
* user may take it (dashboard edit rights) and whether the context allows it
* (target sections present) are runtime state resolved in `usePanelActionItems`.
*/
export const PANEL_ACTION_META: Record<PanelActionId, PanelActionMeta> = {
view: { capability: 'view' },
edit: { permission: 'edit_widget', capability: 'edit' },
clone: { permission: 'edit_widget' },
// Single entry for every export format (CSV/PNG/SVG); like view it isn't
// role-gated (V1 parity). The per-format options live in usePanelActionItems.
edit: { capability: 'edit' },
clone: {},
// Single entry for every export format (CSV/PNG/SVG); the per-format options
// live in usePanelActionItems.
download: { capability: 'download' },
createAlert: { capability: 'createAlert' },
// Moving a panel between sections mutates the dashboard layout.
move: { permission: 'edit_dashboard' },
delete: { permission: 'delete_widget' },
move: {},
delete: {},
};

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useMemo } from 'react';
import { type ReactElement, type ReactNode, useCallback, useMemo } from 'react';
import {
Bell,
Copy,
@@ -9,7 +9,6 @@ import {
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import {
type ConfirmableAction,
useConfirmableAction,
@@ -17,8 +16,6 @@ import {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
@@ -29,9 +26,9 @@ import { useDownloadPanelMenuItem } from '../hooks/useDownloadPanelMenuItem';
import { useMovePanelToSection } from '../hooks/useMovePanelToSection';
import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import { PANEL_ACTION_META } from './panelActionMeta';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -54,10 +51,10 @@ export interface PanelActionItems {
}
/**
* Resolves the panel actions menu items. Each action passes three gates before
* it appears: kind (PanelDefinition.actions), role (useComponentPermission) and
* context (dashboard editable + layout config present). View and Download stay
* available on read-only dashboards, as in V1.
* Resolves the panel actions menu items. Panels live in the dashboard spec and
* have no authz kind, so every mutating action maps to the dashboard's edit
* rights, while PanelDefinition.actions still decides which make sense at all.
* View, Download and Create Alerts never mutate, so they are always available.
*/
export function usePanelActionItems({
panelId,
@@ -66,18 +63,8 @@ export function usePanelActionItems({
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
// edit_widget gates both Edit and Clone, exactly as in V1.
PANEL_ACTION_META.edit.permission ?? 'edit_widget',
PANEL_ACTION_META.move.permission ?? 'edit_dashboard',
PANEL_ACTION_META.delete.permission ?? 'delete_widget',
],
user.role,
);
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
@@ -112,43 +99,43 @@ export function usePanelActionItems({
const { request: requestDelete } = deleteConfirm;
const items = useMemo<MenuItem[]>(() => {
// Edit actions are shown only to edit-permitted users; the lock is their only
// disabled state, surfaced as a hover tooltip on the row.
const canEdit = canEditDashboard;
const label = (text: string): ReactNode =>
isLocked ? (
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
{text}
</DisabledMenuItemLabel>
) : (
text
);
// The row is a button carrying its own icon and reason; the item hosts it.
const row = (
text: string,
icon: ReactElement,
opts: { checks?: BrandedPermission[]; destructive?: boolean } = {},
): ReactNode => (
<MenuActionItem
label={text}
icon={icon}
checks={opts.checks ?? editChecks}
disabledTooltip={editDisabledTooltip}
destructive={opts.destructive}
/>
);
const panelGroup: MenuItem[] = [];
if (panelCapabilities.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
label: row('View', <Fullscreen size={14} />, { checks: [] }),
onClick: (): void => openView(panelId, panel),
});
}
if (canEdit && canEditWidget && panelCapabilities.edit) {
if (panelCapabilities.edit) {
panelGroup.push({
key: 'edit-panel',
label: label('Edit panel'),
icon: <PenLine size={14} />,
disabled: isLocked,
label: row('Edit panel', <PenLine size={14} />),
disabled: !isEditable,
onClick: (): void => openPanelEditor(panelId, { panel }),
});
}
if (canEdit && canEditWidget && panelCapabilities.clone) {
if (panelCapabilities.clone) {
// Needs section context to place the copy; disabled without it.
panelGroup.push({
key: 'clone-panel',
label: label('Clone'),
icon: <Copy size={14} />,
disabled: isLocked || !panelActions,
label: row('Clone', <Copy size={14} />),
disabled: !isEditable || !panelActions,
onClick: (): void => {
if (panelActions) {
void clonePanel({
@@ -170,45 +157,35 @@ export function usePanelActionItems({
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
onClick: (): void => createAlert(panel, panelId),
});
}
let moveGroup: MenuItem[] = [];
if (canEdit && canMove) {
moveGroup =
!isLocked && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [
{
key: 'move',
label: label('Move to section'),
icon: <FolderInput size={14} />,
disabled: true,
},
];
}
const deleteGroup: MenuItem[] =
canEdit && canDelete
? [
const moveGroup: MenuItem[] =
isEditable && panelActions
? buildMoveItems({
sections,
currentLayoutIndex: panelActions.currentLayoutIndex,
panelId,
movePanel,
})
: [
{
key: 'delete-panel',
danger: true,
icon: <Trash2 size={14} />,
label: label('Delete panel'),
disabled: isLocked || !panelActions,
onClick: (): void => requestDelete(),
key: 'move',
label: row('Move to section', <FolderInput size={14} />),
disabled: true,
},
]
: [];
];
const deleteGroup: MenuItem[] = [
{
key: 'delete-panel',
label: row('Delete panel', <Trash2 size={14} />, { destructive: true }),
disabled: !isEditable || !panelActions,
onClick: (): void => requestDelete(),
},
];
return [panelGroup, dataGroup, moveGroup, deleteGroup]
.filter((group) => group.length > 0)
@@ -216,11 +193,9 @@ export function usePanelActionItems({
index === 0 ? group : [{ type: 'divider' as const }, ...group],
);
}, [
canEditDashboard,
isLocked,
canEditWidget,
canMove,
canDelete,
isEditable,
editChecks,
editDisabledTooltip,
panelCapabilities,
panel,
panelActions,

View File

@@ -13,7 +13,8 @@ import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/ty
import type { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
interface ViewPanelModalHeaderProps {
selectedInterval: Time | CustomTimeType;
@@ -62,13 +63,14 @@ function ViewPanelModalHeader({
onChangePanelKind,
onResetQuery,
}: ViewPanelModalHeaderProps): JSX.Element {
const {
isEditable: canSwitchToEdit,
editChecks,
editDisabledTooltip,
} = useDashboardEditContext();
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const canSwitchToEdit = canEditDashboard && !isLocked;
return (
<div className={styles.toolbar}>
@@ -80,17 +82,18 @@ function ViewPanelModalHeader({
onChange={onChangePanelKind}
/>
</div>
{canSwitchToEdit && (
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
disabled={!canSwitchToEdit}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
)}
</AuthZTooltip>
<Button
variant="link"
color="primary"

View File

@@ -3,12 +3,10 @@ import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
@@ -20,6 +18,7 @@ import SectionHeader, {
type SectionDragHandle,
} from '../SectionHeader/SectionHeader';
import styles from './Section.module.scss';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
interface SectionProps {
section: DashboardSection;
@@ -30,8 +29,8 @@ interface SectionProps {
}
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const {
isPickerOpen,
openPicker,
@@ -104,43 +103,34 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
onToggle={toggle}
repeatVariable={section.repeatVariable}
dragHandle={dragHandle}
disabledReason={isLocked ? DASHBOARD_LOCKED_REASON : ''}
actions={
canEditDashboard
? {
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onCloneSection: (): void => void cloneSection(section),
onDeleteSection: (): void => setIsDeleteOpen(true),
}
: undefined
}
disabledChecks={editChecks}
disabledTooltip={editDisabledTooltip}
actions={{
onRename: (): void => setIsRenaming(true),
onAddPanel: (): void => openPicker(section.layoutIndex),
onCloneSection: (): void => void cloneSection(section),
onDeleteSection: (): void => setIsDeleteOpen(true),
}}
/>
{open &&
(section.items.length > 0 ? (
grid
) : (
<div className={styles.emptySection}>
{canEditDashboard && (
<DisabledControlTooltip
reason={DASHBOARD_LOCKED_REASON}
disabled={isLocked}
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
onClick={
isEditable ? (): void => openPicker(section.layoutIndex) : undefined
}
testId={`section-add-panel-${section.id}`}
>
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size="md" />}
disabled={isLocked}
onClick={
isLocked ? undefined : (): void => openPicker(section.layoutIndex)
}
testId={`section-add-panel-${section.id}`}
>
New Panel
</Button>
</DisabledControlTooltip>
)}
New Panel
</Button>
</AuthZTooltip>
</div>
))}
<SectionTitleModal

View File

@@ -1,16 +1,20 @@
import { type ReactNode, useMemo } from 'react';
import { type ReactElement, type ReactNode, useMemo } from 'react';
import { Copy, EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
import menuStyles from '../../../components/MenuActionItem/MenuActionItem.module.scss';
import styles from './SectionActionsMenu.module.scss';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
interface SectionActionsMenuProps {
sectionId: string;
/** Non-empty when edits are unavailable — items render disabled with this reason. */
disabledReason?: string;
/** Present when edits are unavailable — items render disabled with its reason. */
disabledChecks?: BrandedPermission[];
disabledTooltip?: string;
disabled?: boolean;
onAddPanel?: () => void;
onRename?: () => void;
onCloneSection?: () => void;
@@ -19,28 +23,35 @@ interface SectionActionsMenuProps {
function SectionActionsMenu({
sectionId,
disabledReason = '',
disabledChecks = [],
disabledTooltip,
disabled = false,
onAddPanel,
onRename,
onCloneSection,
onDeleteSection,
}: SectionActionsMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(() => {
const disabled = !!disabledReason;
const label = (text: string): ReactNode =>
disabled ? (
<DisabledMenuItemLabel reason={disabledReason}>
{text}
</DisabledMenuItemLabel>
) : (
text
);
// The row is a button, so it carries its own icon, disabled state and
// reason — the dropdown item just hosts it.
const row = (
text: string,
icon: ReactElement,
opts: { destructive?: boolean } = {},
): ReactNode => (
<MenuActionItem
label={text}
icon={icon}
checks={disabledChecks}
disabledTooltip={disabledTooltip}
destructive={opts.destructive}
/>
);
const result: MenuItem[] = [];
if (onAddPanel) {
result.push({
key: 'add-panel',
icon: <Plus size={14} />,
label: label('Add panel'),
label: row('Add panel', <Plus size={14} />),
disabled,
onClick: onAddPanel,
});
@@ -48,8 +59,7 @@ function SectionActionsMenu({
if (onRename) {
result.push({
key: 'rename',
icon: <PenLine size={14} />,
label: label('Rename section'),
label: row('Rename section', <PenLine size={14} />),
disabled,
onClick: onRename,
});
@@ -57,8 +67,7 @@ function SectionActionsMenu({
if (onCloneSection) {
result.push({
key: 'clone-section',
icon: <Copy size={14} />,
label: label('Clone section'),
label: row('Clone section', <Copy size={14} />),
disabled,
onClick: onCloneSection,
});
@@ -68,19 +77,27 @@ function SectionActionsMenu({
{ type: 'divider' },
{
key: 'delete-section',
danger: true,
icon: <Trash2 size={14} />,
label: label('Delete section'),
label: row('Delete section', <Trash2 size={14} />, {
destructive: true,
}),
disabled,
onClick: onDeleteSection,
},
);
}
return result;
}, [disabledReason, onAddPanel, onRename, onCloneSection, onDeleteSection]);
}, [
disabled,
disabledChecks,
disabledTooltip,
onAddPanel,
onRename,
onCloneSection,
onDeleteSection,
]);
return (
<DropdownMenuSimple menu={{ items }}>
<DropdownMenuSimple menu={{ items }} className={menuStyles.menuContent}>
<Button
type="button"
variant="ghost"

View File

@@ -2,10 +2,10 @@ import { useMemo } from 'react';
import GridLayout, { WidthProvider, type Layout } from 'react-grid-layout';
import type { DashboardSection } from '../../../utils';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { usePersistLayout } from '../hooks/usePersistLayout';
import SectionGridItem from './SectionGridItem';
import styles from './SectionGrid.module.scss';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
const ResponsiveGridLayout = WidthProvider(GridLayout);
@@ -21,7 +21,7 @@ function SectionGrid({
layoutIndex,
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isEditable } = useDashboardEditContext();
const rglLayout = useMemo<Layout[]>(
() =>

View File

@@ -6,7 +6,9 @@ import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
import styles from './SectionHeader.module.scss';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
export interface SectionDragHandle {
attributes: DraggableAttributes;
@@ -32,8 +34,9 @@ interface SectionHeaderProps {
dragHandle?: SectionDragHandle;
/** Present for edit-permitted users; absent (no menu) in view mode. */
actions?: SectionHeaderActions;
/** Non-empty when locked — actions render disabled with this reason. */
disabledReason?: string;
/** Present when edits are unavailable — actions render disabled with its reason. */
disabledChecks?: BrandedPermission[];
disabledTooltip?: string;
}
function SectionHeader({
@@ -44,7 +47,8 @@ function SectionHeader({
repeatVariable,
dragHandle,
actions,
disabledReason = '',
disabledChecks = [],
disabledTooltip,
}: SectionHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, { [styles.headerOpen]: open })}>
@@ -83,7 +87,8 @@ function SectionHeader({
{actions ? (
<SectionActionsMenu
sectionId={sectionId}
disabledReason={disabledReason}
disabledChecks={disabledChecks}
disabledTooltip={disabledTooltip}
onAddPanel={actions.onAddPanel}
onRename={actions.onRename}
onCloneSection={actions.onCloneSection}

View File

@@ -11,11 +11,11 @@ import {
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
import type { DashboardSection } from '../../utils';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
interface SectionListProps {
sections: DashboardSection[];
@@ -23,7 +23,7 @@ interface SectionListProps {
}
function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isEditable } = useDashboardEditContext();
const {
sensors,
@@ -41,16 +41,9 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
[orderedSections],
);
if (!isEditable) {
return (
<>
{sections.map((section) => (
<Section key={section.id} section={section} />
))}
</>
);
}
// The DnD tree stays mounted whether or not the user can reorder: permissions
// resolve asynchronously, and swapping the subtree shape on that transition
// would remount every section and its panels.
return (
<DndContext
sensors={sensors}
@@ -63,7 +56,12 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection key={section.id} section={section} sections={sections} />
<SortableSection
key={section.id}
section={section}
sections={sections}
disabled={!isEditable}
/>
) : (
<Section key={section.id} section={section} sections={sections} />
),

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -7,11 +8,14 @@ import Section from './Section/Section';
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
/** Reordering needs edit rights; the section still renders without them. */
disabled?: boolean;
}
function SortableSection({
section,
sections,
disabled = false,
}: SortableSectionProps): JSX.Element {
const {
attributes,
@@ -21,7 +25,14 @@ function SortableSection({
transform,
transition,
isDragging,
} = useSortable({ id: section.id });
} = useSortable({ id: section.id, disabled });
// dnd-kit re-renders this on every drag frame, so keep the handle identity
// stable rather than handing Section a fresh object each time.
const handle = useMemo(
() => (disabled ? undefined : { attributes, listeners, setActivatorNodeRef }),
[disabled, attributes, listeners, setActivatorNodeRef],
);
// dnd-kit drives the drag transform per-frame, so this must be an inline
// style — there is no static-stylesheet equivalent for a live transform.
@@ -35,11 +46,7 @@ function SortableSection({
return (
<div ref={setNodeRef} style={style}>
<Section
section={section}
sections={sections}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
<Section section={section} sections={sections} dragHandle={handle} />
</div>
);
}

View File

@@ -5,7 +5,6 @@ import type {
DashboardtypesPanelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import { useViewPanel } from './Panel/hooks/useViewPanel';
@@ -16,6 +15,7 @@ import styles from './PanelsAndSectionsLayout.module.scss';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
interface PanelsAndSectionsLayoutProps {
layouts: DashboardtypesLayoutDTO[];
@@ -26,7 +26,7 @@ function PanelsAndSectionsLayout({
layouts,
panels,
}: PanelsAndSectionsLayoutProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { isEditable } = useDashboardEditContext();
// Single View-modal host for the whole dashboard, driven by the URL
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location

View File

@@ -16,6 +16,7 @@ import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
@@ -32,9 +33,10 @@ interface VariablesBarProps {
*/
function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
const dashboardId = dashboard.id ?? '';
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const { variables, selection, setSelection, autoSelect } =
useVariableSelection(dashboard);
const isEditable = useDashboardStore((s) => s.isEditable);
// Persisted per dashboard so the full/collapsed view survives reloads.
const expanded = useDashboardStore(selectVariablesExpanded(dashboardId));
const setVariablesExpanded = useDashboardStore((s) => s.setVariablesExpanded);
@@ -131,11 +133,13 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
{/* After the more/less trigger, in every state. Kept inline (not block)
so the row still flows under the floated time selector, and always
mounted so measuring never toggles it. */}
{isEditable && (
<span className={styles.addSlot}>
<AddVariableIcon />
</span>
)}
<span className={styles.addSlot}>
<AddVariableIcon
checks={editChecks}
disabledTooltip={editDisabledTooltip}
isEditable={isEditable}
/>
</span>
</div>
</div>
);

View File

@@ -1,34 +1,70 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { useDashboardStore } from '../../../store/useDashboardStore';
import styles from './AddVariable.module.scss';
interface AddVariableIconProps {
/** Permissions the control needs; reported in the standard wording when denied. */
checks: BrandedPermission[];
/** A non-permission block, which outranks the checks. */
disabledTooltip?: string;
/** Whether editing is available at all, so the label is shown instead. */
isEditable: boolean;
}
/**
* Compact "+" trigger (label on hover) shown after the variable pills once at
* least one variable exists. Opens the Variables settings tab with the add form
* primed.
*/
function AddVariableIcon(): JSX.Element {
function AddVariableIcon({
checks,
disabledTooltip,
isEditable,
}: AddVariableIconProps): JSX.Element {
const requestSettings = useDashboardStore((s) => s.requestSettings);
const onClick = (): void =>
requestSettings({ tab: 'Variables', addVariable: true });
// An available trigger shows its label; an unavailable one is disabled and
// explained by the authz button itself.
if (isEditable) {
return (
<TooltipSimple side="top" title="Add variable">
<Button
variant="outlined"
color="secondary"
size="icon"
className={styles.addVariableIcon}
aria-label="Add variable"
testId="dashboard-variables-add"
onClick={onClick}
>
<Plus size={14} />
</Button>
</TooltipSimple>
);
}
return (
<TooltipSimple side="top" title="Add variable">
<Button
variant="outlined"
color="secondary"
size="icon"
className={styles.addVariableIcon}
aria-label="Add variable"
testId="dashboard-variables-add"
onClick={(): void =>
requestSettings({ tab: 'Variables', addVariable: true })
}
>
<Plus size={14} />
</Button>
</TooltipSimple>
<AuthZButton
checks={checks}
disabledTooltip={disabledTooltip}
variant="outlined"
color="secondary"
size="icon"
className={styles.addVariableIcon}
aria-label="Add variable"
testId="dashboard-variables-add"
onClick={onClick}
>
<Plus size={14} />
</AuthZButton>
);
}

View File

@@ -1,9 +0,0 @@
.trigger {
display: inline-flex;
}
.aboveOverlay {
// Lift the tooltip above the dropdown menu (z 50) and the antd Drawer (z 1000)
// so it is never clipped behind them. The tooltip content reads this variable.
--tooltip-z-index: 1100;
}

View File

@@ -1,33 +0,0 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DisabledControlTooltip.module.scss';
interface DisabledControlTooltipProps {
reason: string;
disabled: boolean;
children: ReactNode;
}
// A disabled button swallows hover, so the wrapping span is the tooltip trigger.
function DisabledControlTooltip({
reason,
disabled,
children,
}: DisabledControlTooltipProps): JSX.Element {
if (!disabled) {
return <>{children}</>;
}
return (
<TooltipSimple
title={reason}
arrow
disableHoverableContent
tooltipContentProps={{ className: styles.aboveOverlay }}
>
<span className={styles.trigger}>{children}</span>
</TooltipSimple>
);
}
export default DisabledControlTooltip;

View File

@@ -1,10 +0,0 @@
.label {
// Re-enable pointer events so the tooltip fires while the disabled row
// (pointer-events: none) suppresses selection.
pointer-events: auto;
}
.aboveOverlay {
// Lift the tooltip above the dropdown menu (z 50) and the antd Drawer (z 1000).
--tooltip-z-index: 1100;
}

View File

@@ -1,28 +0,0 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DisabledMenuItemLabel.module.scss';
interface DisabledMenuItemLabelProps {
reason: string;
children: ReactNode;
}
// A disabled row has pointer-events: none, so the label re-enables them to catch hover.
function DisabledMenuItemLabel({
reason,
children,
}: DisabledMenuItemLabelProps): JSX.Element {
return (
<TooltipSimple
title={reason}
arrow
disableHoverableContent
tooltipContentProps={{ className: styles.aboveOverlay }}
>
<span className={styles.label}>{children}</span>
</TooltipSimple>
);
}
export default DisabledMenuItemLabel;

View File

@@ -0,0 +1,28 @@
.menuActionItem {
/* The item owns the row's height, font, gap, hover and dimming, so anything
the button would size, paint or dim for itself is handed back. Without this
the two compound: a highlight inset in the row, 0.6 opacity on top of 0.5.
Colour stays the button's, from `color`, as on the list rows. */
--button-padding: var(--spacing-5, 10px) var(--spacing-6, 12px);
--button-height: auto;
--button-font-size: inherit;
--button-line-height: inherit;
--button-gap: var(--spacing-4, 8px);
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-internal-hover-state-background-color: transparent;
--button-disabled-opacity: 1;
/* Padding is the exception: it belongs to the button, which is what the
tooltip anchors to. Left on the item, the anchor is inset from the menu's
edge and the tooltip opens under the panel. */
width: 100%;
justify-content: flex-start;
}
/* The other half: the menu hands its padding to the rows, so a row's box reaches
the panel edge, as the list popover's rows do. */
.menuContent {
--dropdown-menu-content-padding: 0;
--dropdown-menu-item-padding: 0;
}

View File

@@ -0,0 +1,47 @@
import type { ReactElement, ReactNode } from 'react';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import styles from './MenuActionItem.module.scss';
interface MenuActionItemProps {
label: ReactNode;
icon: ReactElement;
/** Permissions the row needs. Empty for a row that needs none. */
checks: BrandedPermission[];
/** A non-permission block, which outranks the checks (see AuthZTooltip). */
disabledTooltip?: string;
destructive?: boolean;
}
/**
* A row in a dashboard dropdown. The button fills the row, so the tooltip
* anchors to the whole row and lands clear of the menu.
*
* Takes no `onClick` or `disabled`: the item keeps both, because Radix reads
* `disabled` off it for keyboard navigation and only marks a row `clickable` —
* the pointer cursor — when the item carries the handler.
*/
function MenuActionItem({
label,
icon,
checks,
disabledTooltip,
destructive = false,
}: MenuActionItemProps): JSX.Element {
return (
<AuthZButton
checks={checks}
disabledTooltip={disabledTooltip}
side="left"
variant="ghost"
color={destructive ? 'destructive' : 'secondary'}
className={styles.menuActionItem}
prefix={icon}
>
{label}
</AuthZButton>
);
}
export default MenuActionItem;

View File

@@ -0,0 +1,133 @@
import { server } from 'mocks-server/server';
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { useDashboardEditContext } from '../useDashboardEditContext';
const DASHBOARD_ID = 'dash-1';
// The copy lives in the dashboard i18n bundle, which the test env does not load,
// so `t` yields the key — which is the part worth asserting anyway.
const LOCKED_COPY = 'dashboard_locked';
let lockedDashboard = false;
// The hook reads the dashboard from the loaded subtree, which a bare renderHook
// has no root page to establish — stand in for it so these cases stay about the
// permissions and the derivation.
jest.mock('../useDashboardFetchRequired', () => ({
useDashboardFetchRequired: (): { dashboard: unknown } => ({
dashboard: { id: DASHBOARD_ID, locked: lockedDashboard },
}),
}));
function renderGuard(
locked = false,
): ReturnType<
typeof renderHook<ReturnType<typeof useDashboardEditContext>, void>
> {
lockedDashboard = locked;
return renderHook(() => useDashboardEditContext(), {
wrapper: AllTheProviders,
});
}
describe('useDashboardEditContext - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('permission granted', () => {
it('is editable when unlocked', async () => {
server.use(setupAuthzAdmin());
const { result } = renderGuard();
await waitFor(() => expect(result.current.isEditable).toBe(true));
expect(result.current.editDisabledTooltip).toBe('');
expect(result.current.deleteDisabledTooltip).toBe('');
});
// An edit-capable user gets the lock: it's the thing they can act on.
it('reports the lock when locked', async () => {
server.use(setupAuthzAdmin());
const { result } = renderGuard(true);
// Settle on the resolved grant: the in-flight state has canEdit false, so
// a non-empty reason is not enough to know the check has landed.
await waitFor(() => expect(result.current.canEditDashboard).toBe(true));
expect(result.current.isEditable).toBe(false);
expect(result.current.editDisabledTooltip).toBe(LOCKED_COPY);
expect(result.current.deleteDisabledTooltip).toBe(LOCKED_COPY);
});
});
describe('permission denied', () => {
// Access before state: a lock would send them asking for the wrong thing.
it('reports the permission, not the lock, when both apply', async () => {
server.use(setupAuthzDenyAll());
const { result } = renderGuard(true);
await waitFor(() => expect(result.current.editDisabledTooltip).toBe(''));
expect(result.current.deleteDisabledTooltip).toBe('');
expect(result.current.editDisabledTooltip).toBe('');
});
it('reports the permission when unlocked', async () => {
server.use(setupAuthzDenyAll());
const { result } = renderGuard();
await waitFor(() => expect(result.current.canEditDashboard).toBe(false));
expect(result.current.canEditDashboard).toBe(false);
expect(result.current.editDisabledTooltip).toBe('');
expect(result.current.deleteDisabledTooltip).toBe('');
});
// Authz guide rule 2 — update alone is not enough to offer an edit affordance.
it('is not editable with update but no read', async () => {
server.use(setupAuthzAllow(buildDashboardUpdatePermission(DASHBOARD_ID)));
const { result } = renderGuard();
await waitFor(() => expect(result.current.canEditDashboard).toBe(false));
expect(result.current.canEditDashboard).toBe(false);
expect(result.current.editDisabledTooltip).toBe('');
});
// Authz guide rule 3 — delete stands on its own.
it('keeps delete available without read or update', async () => {
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
const { result } = renderGuard();
await waitFor(() => expect(result.current.canDeleteDashboard).toBe(true));
expect(result.current.deleteDisabledTooltip).toBe('');
expect(result.current.canEditDashboard).toBe(false);
});
it('is editable with read and update together', async () => {
server.use(
setupAuthzAllow(
buildDashboardReadPermission(DASHBOARD_ID),
buildDashboardUpdatePermission(DASHBOARD_ID),
),
);
const { result } = renderGuard();
await waitFor(() => expect(result.current.isEditable).toBe(true));
});
});
});

View File

@@ -0,0 +1,79 @@
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
/** Copy for the two non-permission blocks, resolved by the caller so this stays pure. */
export interface EditContextReasons {
locked: string;
readOnly: string;
}
export interface DashboardEditContext {
isEditable: boolean;
isLocked: boolean;
canReadDashboard: boolean;
canEditDashboard: boolean;
canDeleteDashboard: boolean;
/** `[read, update]`, for an authz component's `checks`. */
editChecks: BrandedPermission[];
deleteChecks: BrandedPermission[];
/** Non-permission obstacle for `disabledTooltip`; empty when a permission is what's missing. */
editDisabledTooltip: string;
deleteDisabledTooltip: string;
/** `update`/`delete` are still resolving; `read` gates the page itself. */
areOtherPermissionsLoading: boolean;
}
/**
* Precedence is `update` → lock → `read`: the lock is only reported to a caller
* who holds `update` and can act on it. A forced read-only mount outranks both,
* since no check can lift it.
*/
export function deriveEditContext({
isLocked,
canRead,
canEdit,
canDelete,
editChecks,
deleteChecks,
areOtherPermissionsLoading,
reasons,
readOnlyOverride = false,
}: {
isLocked: boolean;
canRead: boolean;
canEdit: boolean;
canDelete: boolean;
editChecks: BrandedPermission[];
deleteChecks: BrandedPermission[];
areOtherPermissionsLoading: boolean;
reasons: EditContextReasons;
/** Mount forced view-only regardless of permissions (see pulse-pod#283). */
readOnlyOverride?: boolean;
}): DashboardEditContext {
if (readOnlyOverride) {
return {
isEditable: false,
isLocked,
canReadDashboard: canRead,
canEditDashboard: false,
canDeleteDashboard: false,
editChecks,
deleteChecks,
editDisabledTooltip: reasons.readOnly,
deleteDisabledTooltip: reasons.readOnly,
areOtherPermissionsLoading: false,
};
}
return {
isEditable: canEdit && !isLocked,
isLocked,
canReadDashboard: canRead,
canEditDashboard: canEdit,
canDeleteDashboard: canDelete,
editChecks,
deleteChecks,
editDisabledTooltip: canEdit && isLocked ? reasons.locked : '',
deleteDisabledTooltip: canDelete && isLocked ? reasons.locked : '',
areOtherPermissionsLoading,
};
}

View File

@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDashboardPermissions } from 'hooks/dashboards/useDashboardPermissions';
import { useDashboardStore } from '../store/useDashboardStore';
import {
type DashboardEditContext,
deriveEditContext,
} from './dashboardEditContext';
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
/**
* Edit context for components inside a loaded dashboard subtree. The dashboard
* and the permissions come from their shared caches, keyed off the store's
* `dashboardId`; nothing derived is stored, to avoid a second source of truth.
*/
export function useDashboardEditContext(): DashboardEditContext {
const { dashboard } = useDashboardFetchRequired();
const {
canRead,
canEdit,
canDelete,
editChecks,
deletePermission,
areOtherPermissionsLoading,
} = useDashboardPermissions(dashboard.id);
const { t } = useTranslation('dashboard');
const readOnlyOverride = useDashboardStore(
(s) => s.canEditDashboardOverride === false,
);
const deleteChecks = useMemo(() => [deletePermission], [deletePermission]);
return deriveEditContext({
isLocked: !!dashboard.locked,
canRead,
canEdit,
canDelete,
editChecks,
deleteChecks,
areOtherPermissionsLoading,
reasons: {
locked: t('dashboard_locked'),
readOnly: t('dashboard_read_only_here'),
},
readOnlyOverride,
});
}

View File

@@ -1,48 +0,0 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
// Re-exported from the (dependency-light) slice so leaf modules / tests can import
// the reason strings without pulling this hook's provider chain.
export {
DASHBOARD_LOCKED_REASON,
DASHBOARD_NO_EDIT_PERMISSION_REASON,
} from '../store/slices/editContextSlice';
export interface DashboardEditGuard {
isEditable: boolean;
isLocked: boolean;
canEditDashboard: boolean;
editDisabledReason: string;
}
// Editability + reason, derived from the dashboard (used where the store is cold,
// e.g. the panel-editor route reached by direct URL).
export function useDashboardEditGuard(
dashboard: DashboardtypesGettableDashboardV2DTO | undefined,
): DashboardEditGuard {
const { user } = useAppContext();
const [editDashboardPermission] = useComponentPermission(
['edit_dashboard'],
user.role,
);
const canEditDashboard = !!editDashboardPermission;
const isLocked = !!dashboard?.locked;
let editDisabledReason = '';
if (isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
return {
isEditable: canEditDashboard && !isLocked,
isLocked,
canEditDashboard,
editDisabledReason,
};
}

View File

@@ -12,8 +12,8 @@ import type {
import APIError from 'types/api/error';
import { applyJsonPatch } from '../optimistic/applyJsonPatch';
import { DASHBOARD_LOCKED_REASON } from '../store/slices/editContextSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { useTranslation } from 'react-i18next';
/** Cached dashboard snapshot, kept for rollback on error. */
interface OptimisticPatchContext {
@@ -37,8 +37,8 @@ export function useOptimisticPatch(
dashboardIdOverride?: string,
): UseOptimisticPatch {
const storeDashboardId = useDashboardStore((s) => s.dashboardId);
const storeIsEditable = useDashboardStore((s) => s.isEditable);
const dashboardId = dashboardIdOverride ?? storeDashboardId;
const { t } = useTranslation('dashboard');
const queryClient = useQueryClient();
const queryKey = getGetDashboardV2QueryKey({ id: dashboardId });
@@ -78,12 +78,16 @@ export function useOptimisticPatch(
const { mutateAsync } = mutation;
const patchAsync = useCallback(
(ops: DashboardtypesJSONPatchOperationDTO[]): Promise<unknown> => {
if (storeDashboardId === dashboardId && !storeIsEditable) {
return Promise.reject(new Error(DASHBOARD_LOCKED_REASON));
// Defense-in-depth against a lock, read straight from the cache this hook
// already owns. Permission is enforced by the UI and, definitively, by the
// backend — checking it here would drag authz into every mutation.
const cached = queryClient.getQueryData<GetDashboardV2200>(queryKey);
if (cached?.data?.locked) {
return Promise.reject(new Error(t('dashboard_locked')));
}
return mutateAsync(ops);
},
[storeDashboardId, dashboardId, storeIsEditable, mutateAsync],
[queryClient, queryKey, mutateAsync],
);
return {

View File

@@ -6,7 +6,6 @@ import AutoRefreshTicker from 'container/TopNav/AutoRefreshV2/AutoRefreshTicker'
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useDashboardEditGuard } from './hooks/useDashboardEditGuard';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useSyncVariablesForSuggestions } from './hooks/useSyncVariablesForSuggestions';
import { useDashboardStore } from './store/useDashboardStore';
@@ -50,17 +49,13 @@ function DashboardContainer({
const fullScreenHandle = useFullScreenHandle();
const { isLocked, canEditDashboard } = useDashboardEditGuard(dashboard);
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard: canEditDashboardOverride ?? canEditDashboard,
refetch,
canEditDashboardOverride,
});
// Resolve the variable selection into the V5 query payload and publish it to
@@ -88,7 +83,7 @@ function DashboardContainer({
</>
)}
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
{isLocked && <LockedIndicator />}
{!!dashboard.locked && <LockedIndicator />}
<DashboardChangedDialog
open={staleCheck.showPrompt}
onReload={staleCheck.reload}

View File

@@ -2,25 +2,23 @@ import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
export const DASHBOARD_LOCKED_REASON = 'This dashboard is locked';
export const DASHBOARD_NO_EDIT_PERMISSION_REASON =
'You dont have permission to edit this dashboard';
// Edit context shared across the V2 dashboard tree, set once by DashboardContainer.
/**
* The one piece of page context the subtree can't derive for itself: which
* dashboard is open, and how to refetch it. Editability is deliberately absent —
* `useDashboardEditContext` derives it from the caches.
*/
export interface EditContextSlice {
dashboardId: string;
// canEditDashboard && !isLocked.
isEditable: boolean;
isLocked: boolean;
canEditDashboard: boolean;
// Locked / no-permission reason for tooltips; '' when editable.
editDisabledReason: string;
refetch: () => void;
/**
* @deprecated Forces a view-only mount regardless of permissions. Used only by
* LLM Observability; see SigNoz/pulse-pod#283.
*/
canEditDashboardOverride?: boolean;
setEditContext: (ctx: {
dashboardId: string;
isLocked: boolean;
canEditDashboard: boolean;
refetch: () => void;
canEditDashboardOverride?: boolean;
}) => void;
}
@@ -31,37 +29,22 @@ export const createEditContextSlice: StateCreator<
EditContextSlice
> = (set, get) => ({
dashboardId: '',
isEditable: false,
isLocked: false,
canEditDashboard: false,
editDisabledReason: '',
refetch: (): void => undefined,
canEditDashboardOverride: undefined,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const isEditable = ctx.canEditDashboard && !ctx.isLocked;
let editDisabledReason = '';
if (ctx.isLocked) {
editDisabledReason = DASHBOARD_LOCKED_REASON;
} else if (!ctx.canEditDashboard) {
editDisabledReason = DASHBOARD_NO_EDIT_PERMISSION_REASON;
}
const prev = get();
if (
prev.dashboardId === ctx.dashboardId &&
prev.isEditable === isEditable &&
prev.isLocked === ctx.isLocked &&
prev.canEditDashboard === ctx.canEditDashboard &&
prev.refetch === ctx.refetch
prev.refetch === ctx.refetch &&
prev.canEditDashboardOverride === ctx.canEditDashboardOverride
) {
return;
}
set({
dashboardId: ctx.dashboardId,
isEditable,
isLocked: ctx.isLocked,
canEditDashboard: ctx.canEditDashboard,
editDisabledReason,
refetch: ctx.refetch,
canEditDashboardOverride: ctx.canEditDashboardOverride,
});
},
});

View File

@@ -4,6 +4,12 @@ import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import DashboardContainer from './DashboardContainer';
@@ -49,4 +55,15 @@ function DashboardPage(): JSX.Element {
return <DashboardContainer dashboard={dashboard} refetch={refetch} />;
}
export default DashboardPage;
// Typed explicitly: the route lazy-loads this, and Loadable needs indexable props.
export default withAuthZPage<Record<string, unknown>>(DashboardPage, {
checks: (_props, router) => [
buildDashboardReadPermission(router.params.dashboardId ?? ''),
],
// Same batch as `checks`, so the controls below resolve from cache.
preloadChecks: (_props, router) => [
buildDashboardUpdatePermission(router.params.dashboardId ?? ''),
buildDashboardDeletePermission(router.params.dashboardId ?? ''),
],
fallbackOnLoading: <Spinner tip="Loading dashboard..." />,
});

View File

@@ -10,9 +10,14 @@ import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import {
buildDashboardDeletePermission,
buildDashboardReadPermission,
buildDashboardUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { useDashboardEditGuard } from '../DashboardContainer/hooks/useDashboardEditGuard';
import { useResolvedVariables } from '../DashboardContainer/hooks/useResolvedVariables';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
@@ -47,22 +52,12 @@ function PanelEditorPage(): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } =
useDashboardFetch(dashboardId);
// Derived here (not from the store) because the editor route doesn't mount
// DashboardContainer, so the store's edit context may be cold on a direct URL.
const { isEditable, isLocked, canEditDashboard, editDisabledReason } =
useDashboardEditGuard(dashboard);
// On a refresh/direct URL this route is the only mount, so seed the edit
// context the way DashboardContainer does — during render, so the subtree's
// first render already sees the id (useDashboardFetchRequired throws without it).
const setEditContext = useDashboardStore((s) => s.setEditContext);
if (dashboard?.id) {
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard,
refetch,
});
setEditContext({ dashboardId: dashboard.id, refetch });
}
// No variables bar on this route: seed the selection and publish the resolved
@@ -143,12 +138,21 @@ function PanelEditorPage(): JSX.Element {
savedPanel={existingPanel}
isNew={!!newKind}
layoutIndex={layoutIndex}
isEditable={isEditable}
editDisabledReason={editDisabledReason}
onClose={backToDashboard}
onSaved={backToDashboard}
/>
);
}
export default PanelEditorPage;
// Typed explicitly: the route lazy-loads this, and Loadable needs indexable props.
export default withAuthZPage<Record<string, unknown>>(PanelEditorPage, {
checks: (_props, router) => [
buildDashboardReadPermission(router.params.dashboardId ?? ''),
],
// Same batch as `checks`, so the controls below resolve from cache.
preloadChecks: (_props, router) => [
buildDashboardUpdatePermission(router.params.dashboardId ?? ''),
buildDashboardDeletePermission(router.params.dashboardId ?? ''),
],
fallbackOnLoading: <Spinner tip="Loading dashboard..." />,
});

View File

@@ -0,0 +1,154 @@
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import DashboardPage from '../DashboardPage';
const DASHBOARD_ID = 'dash-1';
const DASHBOARD_URL = `http://localhost/api/v2/dashboards/${DASHBOARD_ID}`;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { dashboardId: string } => ({ dashboardId: DASHBOARD_ID }),
}));
describe('DashboardPage - AuthZ', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
// The list is collection-scoped, so an unreadable row is expected, not a failure.
it('blocks the page when the read check is denied', async () => {
server.use(setupAuthzDenyAll());
render(<DashboardPage />);
await expect(
screen.findByText('Uh-oh! You are not authorized'),
).resolves.toBeInTheDocument();
expect(
screen.getByText(`read:dashboard:${DASHBOARD_ID}`),
).toBeInTheDocument();
expect(
screen.queryByText('Failed to load dashboard'),
).not.toBeInTheDocument();
});
// The check is the only authority; a 403 from the GET is an API failure.
it('shows the generic load error for a 403 from the dashboard request', async () => {
server.use(
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
res(
ctx.status(403),
ctx.json({
status: 'error',
error: {
type: 'forbidden',
code: 'authz_forbidden',
message: 'user/x is not authorized to perform dashboard:read',
},
}),
),
),
);
render(<DashboardPage />);
await expect(
screen.findByText('Failed to load dashboard'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});
// The page must not paint controls and then disable them once the check lands.
it('holds the page on the spinner until the permission check resolves', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: DASHBOARD_ID,
spec: {
display: { name: 'Checkout' },
panels: {},
layouts: [],
variables: [],
},
},
}),
),
),
);
render(<DashboardPage />);
// Dashboard data has arrived, but the tree stays unmounted.
await expect(screen.findByLabelText('loading')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('show-drawer')).not.toBeInTheDocument();
expect(screen.queryByTestId('add-panel-header')).not.toBeInTheDocument();
});
// An authz outage must not make every dashboard look forbidden.
it('renders the dashboard when the permission check itself fails', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: DASHBOARD_ID,
spec: {
display: { name: 'Checkout' },
panels: {},
layouts: [],
variables: [],
},
},
}),
),
),
);
render(<DashboardPage />);
await waitFor(() => {
expect(
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});
});
it('still shows the generic error for a server failure', async () => {
server.use(
rest.get(DASHBOARD_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error', error: {} })),
),
);
render(<DashboardPage />);
await expect(
screen.findByText('Failed to load dashboard'),
).resolves.toBeInTheDocument();
expect(
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,17 @@
import { LayoutGrid } from '@signozhq/icons';
import Spinner from 'components/Spinner';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useDashboardCollectionPermissions } from 'hooks/dashboards/useDashboardCollectionPermissions';
import DashboardsList from './components/DashboardsList/DashboardsList';
import styles from './DashboardsListPage.module.scss';
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
function DashboardsListPage(): JSX.Element {
// Resolved before the list mounts, so no control renders enabled-then-disabled.
const { isLoading } = useDashboardCollectionPermissions();
return (
<div className={styles.page}>
<div className={styles.header}>
@@ -19,7 +24,7 @@ function DashboardsListPage(): JSX.Element {
enableFeedback
/>
</div>
<DashboardsList />
{isLoading ? <Spinner tip="Loading dashboards..." /> : <DashboardsList />}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import type { MouseEvent, ReactElement, ReactNode } from 'react';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import styles from './ActionsPopover.module.scss';
interface Props {
label: ReactNode;
icon: ReactElement;
testId: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
/** Permissions the row needs; the component words a refusal. */
checks: BrandedPermission[];
/** A non-permission block, which outranks the checks (see AuthZTooltip). */
disabledTooltip?: string;
loading?: boolean;
destructive?: boolean;
}
/**
* A row in the actions menu. The button fills the row, so the tooltip anchors to
* it and lands clear of the menu rather than over the row's icon.
*/
function ActionsMenuItem({
label,
icon,
testId,
onClick,
checks,
disabledTooltip,
loading = false,
destructive = false,
}: Props): JSX.Element {
return (
<AuthZButton
checks={checks}
disabledTooltip={disabledTooltip}
side="left"
variant="ghost"
color={destructive ? 'destructive' : 'secondary'}
className={styles.menuItem}
prefix={icon}
disabled={loading}
loading={loading}
onClick={(e: MouseEvent<HTMLButtonElement>): void => {
e.preventDefault();
e.stopPropagation();
onClick(e);
}}
testId={testId}
>
{label}
</AuthZButton>
);
}
export default ActionsMenuItem;

View File

@@ -0,0 +1,180 @@
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzAllow,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import {
buildDashboardDeletePermission,
buildDashboardUpdatePermission,
DashboardCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
import ActionsPopover from './ActionsPopover';
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
const DASHBOARD_ID = 'abc';
const baseProps = {
link: '/dashboard/abc',
dashboardId: DASHBOARD_ID,
dashboardName: 'My Dashboard',
createdBy: 'someone-else@signoz.io',
source: DashboardtypesSourceDTO.user,
isLocked: false,
tags: [],
onView: jest.fn(),
};
async function openMenu(): Promise<void> {
await userEvent.click(screen.getByTestId('dashboard-action-icon'));
await screen.findByTestId('dashboard-action-rename');
}
// The authz component puts the denied scopes on the control it disables.
function deniedScopes(testId: string): string | null {
return screen.getByTestId(testId).getAttribute('data-denied-permissions');
}
describe('ActionsPopover - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('laziness', () => {
it('fires no permission check until the menu is opened', async () => {
const onCheck = jest.fn();
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
onCheck();
const payload = await req.json();
return res(
ctx.status(200),
ctx.json({ data: payload, status: 'success' }),
);
}),
);
render(<ActionsPopover {...baseProps} />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onCheck).not.toHaveBeenCalled();
});
});
describe('permission denied', () => {
it('disables the edit actions and explains why when update is denied', async () => {
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
render(<ActionsPopover {...baseProps} />);
await openMenu();
await waitFor(() => {
expect(deniedScopes('dashboard-action-rename')).toContain(
buildDashboardUpdatePermission(DASHBOARD_ID),
);
});
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
expect(screen.getByTestId('dashboard-action-edit-tags')).toBeDisabled();
// Read-only actions and delete are unaffected.
expect(screen.getByTestId('dashboard-action-view')).toBeEnabled();
expect(screen.getByTestId('dashboard-action-delete')).toBeEnabled();
});
// Authz guide rule 3: delete does not depend on read.
it('keeps delete usable for a user holding only delete', async () => {
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
render(<ActionsPopover {...baseProps} />);
await openMenu();
await waitFor(() => {
expect(screen.getByTestId('dashboard-action-delete')).toBeEnabled();
});
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
expect(screen.getByTestId('dashboard-action-duplicate')).toBeDisabled();
});
it('disables delete and explains why when delete is denied', async () => {
server.use(setupAuthzDeny(buildDashboardDeletePermission(DASHBOARD_ID)));
render(<ActionsPopover {...baseProps} />);
await openMenu();
await waitFor(() => {
expect(deniedScopes('dashboard-action-delete')).toBe(
buildDashboardDeletePermission(DASHBOARD_ID),
);
});
expect(screen.getByTestId('dashboard-action-delete')).toBeDisabled();
expect(screen.getByTestId('dashboard-action-rename')).toBeEnabled();
});
it('disables duplicate without create, leaving rename usable', async () => {
server.use(setupAuthzDeny(DashboardCreatePermission));
render(<ActionsPopover {...baseProps} />);
await openMenu();
await waitFor(() => {
expect(screen.getByTestId('dashboard-action-rename')).toBeEnabled();
});
expect(screen.getByTestId('dashboard-action-duplicate')).toBeDisabled();
});
});
describe('lock', () => {});
describe('locked dashboard', () => {
// Access before state: without the permission, the lock is the wrong thing
// to point at.
it('reports the permission, not the lock, without edit rights', async () => {
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
render(<ActionsPopover {...baseProps} isLocked />);
await openMenu();
await waitFor(() => {
expect(deniedScopes('dashboard-action-rename')).toContain(
buildDashboardUpdatePermission(DASHBOARD_ID),
);
});
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
});
it('reports the lock, not the permission, for an editor', async () => {
server.use(setupAuthzAdmin());
render(<ActionsPopover {...baseProps} isLocked />);
await openMenu();
// Duplicate is not lock-gated, so it resolving marks the checks as settled.
await waitFor(() => {
expect(screen.getByTestId('dashboard-action-duplicate')).toBeEnabled();
});
expect(screen.getByTestId('dashboard-action-rename')).toBeDisabled();
expect(deniedScopes('dashboard-action-rename')).toBeNull();
});
});
describe('permission granted', () => {
it('enables every action for an admin', async () => {
server.use(setupAuthzAdmin());
render(<ActionsPopover {...baseProps} />);
await openMenu();
await waitFor(() => {
expect(screen.getByTestId('dashboard-action-rename')).toBeEnabled();
});
expect(screen.getByTestId('dashboard-action-edit-tags')).toBeEnabled();
expect(screen.getByTestId('dashboard-action-duplicate')).toBeEnabled();
expect(screen.getByTestId('dashboard-action-delete')).toBeEnabled();
});
});
});

View File

@@ -43,6 +43,11 @@
pointer-events: none;
}
/* With the button's pointer events off, the wrapper paints the cursor. */
.menuItemWrap:has(button:disabled) {
cursor: not-allowed;
}
.deleteName {
color: var(--danger-background);
font-weight: var(--font-weight-medium);

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