Compare commits

..

12 Commits

Author SHA1 Message Date
Naman Verma
c8e9e362f7 feat: add spec for text panel (#12711)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

Add a new plugin schema for text panel. This also adds a check on the
query count. If the panel is text, then number of queries should be
zero, otherwise it should be 1.

Frontend changes to be built on top of this

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/303
Closes https://github.com/SigNoz/pulse-pod/issues/221

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-09-11 13:29:01 +00:00
Naman Verma
4175f84815 feat: add remaining v2 notification channel apis (#12786)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Create v2 API already added, this PR adds get, update, list, delete, and
test APIs. List API adds sorting, filtering, and pagination.

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

Closes https://github.com/SigNoz/pulse-pod/issues/330
Closes https://github.com/SigNoz/pulse-pod/issues/237
2026-09-11 10:27:22 +00:00
Naman Verma
fd032291f9 feat: add heatmap support in query (#12764)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Heatmap support here is only for metrics (except exponential histograms)
via all three query types: builder, clickhouse and promql. Logs and
traces can be plugged in into this later.

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

Closes https://github.com/SigNoz/pulse-pod/issues/311
2026-09-11 10:19:43 +00:00
Abhi kumar
851abd2c93 feat(dashboards): add the Text panel's Markdown editor and renderer (#12712)
#### Description

Building blocks for the Text panel
([TDD](https://app.notion.com/p/signoz/Text-Panel-Frontend-TDD-3c7fcc6bcd19807cb505f7a1648aba5a)),
ahead of the panel kind itself. Nothing imports them yet — the kind and
its `definition.ts` land in #12742 on top of this.

**`components/MarkdownEditor/`** — the authoring surface that will
replace the query-builder pane for query-less kinds (D8). Formatting
toolbar, searchable insert-variable menu with kind badges (inserting
`$name`, the canonical syntax of the four the renderer resolves), caret
+ character-count status bar.

- Toolbar commands are pure `snapshot → snapshot` transforms with no
CodeMirror coupling, so they're testable directly and a new action is
one registry entry plus an icon.
- Markdown colouring is a decoration pass rather than a grammar — no
`@codemirror/lang-markdown` / `@lezer` dependency for what the renderer
parses for real anyway.
- The document is **uncontrolled**, as in `QuerySearch`. A `value` prop
reaching CodeMirror lets a stale echo replace the document mid-keystroke
and reset the caret; the seed runs from an `isEditorReady`-gated effect
because the wrapper reconciles its own `value` to `''` once the view
exists.

**`Panels/kinds/TextPanel/`** — the renderer. Panel-local rather than
shared, per D4/§13: the shared `MarkdownRenderer` enables `rehype-raw`,
which is safe only for the trusted content it was built for.

- No `rehype-raw`, ever. Raw HTML renders as text; there's no
`dangerouslySetInnerHTML` on the path, so nothing to sanitise. A
rejected `javascript:` href drops the anchor rather than rendering
react-markdown's inert `javascript:void(0)` stand-in.
- The stylesheet reverts the subtree to user-agent styling, so no global
rule reaches the rendered body and these are the only author styles that
apply. Custom properties survive `all`, so theming still flows in — as
does `text-align`, which the panel's presentation options will set on an
ancestor. Injected UI islands opt out through `[data-md-ui]`, matched
inside `:where()` so the exemption adds no specificity of its own.
- Prism runs with `useInlineStyles` off, so the token palette stays on
design tokens. Languages load per fence via dynamic import. Each fenced
block carries the shared periscope copy button, revealed on hover or
focus.

**`Panels/utils/markdownSource.ts`** — write-back, so a rendered element
can edit the body it came from. `EditableConstruct` declares where a
construct's occurrences are and how to rewrite one;
`editRenderedOccurrence` maps a rendered element back to the same
ordinal in the source. GFM task lists are the first construct, so a
checkbox toggles its own marker.

- Variables are interpolated before parsing, so a rendered offset is not
a source offset. The ordinal only holds while both bodies carry the same
number of the construct, so a variable value carrying a marker of its
own refuses the edit rather than ticking the wrong box. Fenced code is
masked out for the same reason.
- Read-only surfaces pass no capability and keep the disabled checkboxes
they render today.

`jest.config.ts` gains `remark-gfm` and its ESM-only dependencies —
nothing had exercised the plugin under jest before.

#### Additional Information

- **Now based directly on `main`.** #12778#12777 have merged, and the
branch has been rebased onto them: it previously carried eleven commits
already in `main` as those two squashes, so the PR was showing work that
was already in. #12742 sits on top of this.
- Three commits: the editor, the renderer, then write-back. The first
two don't import each other.
- Language chunking is only a partial win: `bash`, `docker`, `go`,
`javascript`, `json`, `rust`, `typescript` and `yaml` still emit 62-byte
proxies into the main bundle because
`components/MarkdownRenderer/syntaxHighlighter.ts` imports the same
Prism modules statically. The rest split cleanly. Converting the shared
renderer to the same loader shape would finish the job — deliberately
out of scope here.
- The checkbox the AST synthesises carries no position, so its list item
publishes one for the toggle to resolve against.
- **Follow-up worth its own issue:** `rerender` is a no-op with this
repo's `render`. `customRender` passes `wrapper: () =>
<AllTheProviders>{ui}</AllTheProviders>`, closing over the original
element and ignoring `children`, so `rerender(newUi)` re-renders the old
tree. Two tests here passed vacuously before I noticed; they now use
stateful harnesses instead. Any existing suite relying on `rerender` is
asserting nothing.
- CommonMark line-break semantics are kept as-is (a single newline is a
space, not a `<br>`), matching Grafana's text panel for import fidelity.
The editor's help popover leads with the two structural rules since it's
the first thing authors trip over.
- 76 tests across the three commits; `tsc --noEmit`, `oxlint`,
`stylelint` and `pnpm build` clean.
2026-09-11 10:14:54 +00:00
Nityananda Gohain
c41899f2eb feat: support ai trace alerts (#12783)
#### Description
- Add the `AI_TRACES_BASED_ALERT` alert type so alerts can be built with
the AI explorer's `builder_ai_query`.
- Treat AI trace queries like trace queries for new-series filtering and
related links.
- Related links for AI alerts open the AI observability explorer and tag
the shared query as `builder_ai_query`, so `trace.*` aggregate fields in
the filter resolve. Existing logs and traces links are unchanged.
- Rule history timeline and top-contributor responses gain
`relatedAITracesLink`; AI alerts populate it and leave
`relatedTracesLink` empty so the frontend can route without
  checking the alert type.

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


#### Additional Information
Notifications keep the existing `related_traces` annotation, now
carrying the AI explorer URL, so every channel and `$trace.url` template
keeps working.
2026-09-11 09:27:49 +00:00
Abhi kumar
24b6debd78 refactor(dashboards): split panels into query and static authoring modes (#12778)
#### Description

Lifts the shared panel-mode infrastructure out of #12742 so that PR only
adds the Text panel itself. These are the same four commits, unmodified
apart from the rebase.

- `PanelDefinition` becomes a union discriminated by a root `mode`.
Query kinds declare their whole query surface; a kind without one
carries no query declarations at all, instead of dummy capabilities and
empty signal lists standing in for "not applicable".
- Hosts fork on `mode` and pass the narrowed definition down: `Panel`
and `PublicPanel` become hookless forks over extracted
`QueryPanel`/`QueryPublicPanel` bodies, and the editor and View modal
fork the same way. No query machinery mounts for a kind that has no
query, and leaf query hooks keep non-null contracts.

No behaviour change on `main`: the generated types still describe seven
query kinds, so the static arm of every fork is unreachable until the
Text panel lands.

#### Additional Information

- **Stacked on #12777** — review that first; this PR's diff is only the
four commits above (55 files).
- Commit by commit is the easiest read: the type split, then one commit
per host.
- Verified after the rebase: `tsgo --noEmit` clean, `oxlint` clean, and
the full `DashboardPage` + `PublicDashboard` suites (146 suites, 1141
tests).
- One infra-shaped commit from #12742 is deliberately **not** here:
"share panel chrome across authoring modes" conflicts on exactly the
five files the Text panel commit edits, so it depends on the feature and
stays with it.
- `requireQueryPanelDefinition` asserts the query arm at three call
sites that read the definition by kind rather than receiving it as a
prop. Two of them (`useDrilldown`, `usePanelEditSession`) could take the
narrowed definition from hosts that already hold it; the third is
reached through `PanelHeader`, which both arms mount, so its real guard
is the `actions.createAlert` capability. Worth a follow-up once this and
#12742 have landed, rather than diverging this branch from the commits
#12742 carries.
2026-09-11 08:17:01 +00:00
Abhi kumar
2134adf735 refactor(dashboards): declare panel-kind facts once, in the kind's definition (#12777)
#### Description

Adding a panel kind today means touching several places that switch on
the kind, none of which fail if you miss them — the kind just silently
loses a feature. This closes the four on the frontend so that
registering a kind is enough.

- **Picker and kind switcher read the registry.** Both were driven by a
hand-maintained `PANEL_TYPES` array; a kind missing from it renders on a
saved dashboard but can never be created or switched to. Each kind
declares its icon next to its `displayName`, and both surfaces render
`Object.values(PANELS)`. Registry declaration order is now display
order, so `registry.ts` is reordered to keep today's tile order.
- **Alert unit and threshold prefill read the declarations.**
`readPanelUnit` listed the four kinds carrying a unit and
`readPanelThresholds` listed each kind's threshold shape — both facts
the kind already declares in its `sections.ts`. A new
`getSectionControls` reads them, and replaces a private copy of the same
lookup in `newPanelSeed`.
- **The legend colors control carries its resolver.** `useLegendSeries`
switched on the kind to choose pie slices vs. flat series, while the
kinds exposing the control already declared `Legend.controls.colors`.
The control is now typed as the resolver itself, so declaring one is
declaring the other and they cannot disagree.
- `legendSeries.ts` moves from `PanelEditor/utils` to `Panels/utils` —
it only ever imported from `Panels` and `queryV5`, and kinds can't
import up into the editor.

No behaviour change. Adding a kind on the frontend is now one
`kinds/<Kind>/` folder and one line in `registry.ts`; miss anything else
and it's a compile error.

#### Additional Information

- Three commits, one per concern — reviewing commit by commit is
easiest.
- Verified per commit via the pre-commit hooks (`tsgo --noEmit`,
`oxlint`, `oxfmt`), and at head: `pnpm build` plus the full
`DashboardPage` and `PublicDashboard` suites (145 suites, 1136 tests).
- Tile order in the picker and switcher is unchanged — checked against
the deleted array.
- A follow-up branch lifts the shared query/static panel-mode
infrastructure out of #12742 on top of this.
2026-09-11 07:00:12 +00:00
Ashwin Bhatkal
8ee9f97f28 feat(dashboards): gate the dashboards list on fine-grained authz (#12449)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

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
401 changed files with 25292 additions and 4308 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,

File diff suppressed because it is too large Load Diff

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

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

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

@@ -21,14 +21,23 @@ import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
DeleteNotificationChannelPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
GetNotificationChannel200,
GetNotificationChannelPathParameters,
ListChannels200,
ListNotificationChannels200,
ListNotificationChannelsParams,
RenderErrorResponseDTO,
UpdateChannelByIDPathParameters,
UpdateNotificationChannel200,
UpdateNotificationChannelPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -649,6 +658,105 @@ export const useTestChannelDeprecated = <
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));
};
/**
* Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List notification channels
*/
export const listNotificationChannels = (
params?: ListNotificationChannelsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNotificationChannels200>({
url: `/api/v2/notification_channels`,
method: 'GET',
params,
signal,
});
};
export const getListNotificationChannelsQueryKey = (
params?: ListNotificationChannelsParams,
) => {
return [`/api/v2/notification_channels`, ...(params ? [params] : [])] as const;
};
export const getListNotificationChannelsQueryOptions = <
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListNotificationChannelsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListNotificationChannelsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listNotificationChannels>>
> = ({ signal }) => listNotificationChannels(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListNotificationChannelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listNotificationChannels>>
>;
export type ListNotificationChannelsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List notification channels
*/
export function useListNotificationChannels<
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListNotificationChannelsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListNotificationChannelsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List notification channels
*/
export const invalidateListNotificationChannels = async (
queryClient: QueryClient,
params?: ListNotificationChannelsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListNotificationChannelsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a notification channel
* @summary Create notification channel
@@ -733,3 +841,370 @@ export const useCreateNotificationChannel = <
> => {
return useMutation(getCreateNotificationChannelMutationOptions(options));
};
/**
* This endpoint deletes a notification channel by ID
* @summary Delete notification channel
*/
export const deleteNotificationChannel = (
{ id }: DeleteNotificationChannelPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/notification_channels/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
> => {
const mutationKey = ['deleteNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
{ pathParams: DeleteNotificationChannelPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteNotificationChannel(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteNotificationChannel>>
>;
export type DeleteNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete notification channel
*/
export const useDeleteNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
> => {
return useMutation(getDeleteNotificationChannelMutationOptions(options));
};
/**
* This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.
* @summary Get notification channel by ID
*/
export const getNotificationChannel = (
{ id }: GetNotificationChannelPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetNotificationChannel200>({
url: `/api/v2/notification_channels/${id}`,
method: 'GET',
signal,
});
};
export const getGetNotificationChannelQueryKey = ({
id,
}: GetNotificationChannelPathParameters) => {
return [`/api/v2/notification_channels/${id}`] as const;
};
export const getGetNotificationChannelQueryOptions = <
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetNotificationChannelPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetNotificationChannelQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getNotificationChannel>>
> = ({ signal }) => getNotificationChannel({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetNotificationChannelQueryResult = NonNullable<
Awaited<ReturnType<typeof getNotificationChannel>>
>;
export type GetNotificationChannelQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get notification channel by ID
*/
export function useGetNotificationChannel<
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetNotificationChannelPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetNotificationChannelQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get notification channel by ID
*/
export const invalidateGetNotificationChannel = async (
queryClient: QueryClient,
{ id }: GetNotificationChannelPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetNotificationChannelQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.
* @summary Update notification channel
*/
export const updateNotificationChannel = (
{ id }: UpdateNotificationChannelPathParameters,
alertmanagertypesUpdatableNotificationChannelDTO?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateNotificationChannel200>({
url: `/api/v2/notification_channels/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesUpdatableNotificationChannelDTO,
signal,
});
};
export const getUpdateNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
> => {
const mutationKey = ['updateNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateNotificationChannel>>,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateNotificationChannel(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof updateNotificationChannel>>
>;
export type UpdateNotificationChannelMutationBody =
| BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>
| undefined;
export type UpdateNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update notification channel
*/
export const useUpdateNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
> => {
return useMutation(getUpdateNotificationChannelMutationOptions(options));
};
/**
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
* @summary Test notification channel
*/
export const testNotificationChannel = (
alertmanagertypesTestableNotificationChannelDTO?: BodyType<AlertmanagertypesTestableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/notification_channels/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesTestableNotificationChannelDTO,
signal,
});
};
export const getTestNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
> => {
const mutationKey = ['testNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testNotificationChannel>>,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> }
> = (props) => {
const { data } = props ?? {};
return testNotificationChannel(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof testNotificationChannel>>
>;
export type TestNotificationChannelMutationBody =
| BodyType<AlertmanagertypesTestableNotificationChannelDTO>
| undefined;
export type TestNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test notification channel
*/
export const useTestNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
> => {
return useMutation(getTestNotificationChannelMutationOptions(options));
};

View File

@@ -4,15 +4,23 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -121,3 +129,175 @@ export const useHandleExportRawDataPOST = <
> => {
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
url: `/api/v1/logs/promote_paths`,
method: 'GET',
signal,
});
};
export const getListPromotedAndIndexedPathsQueryKey = () => {
return [`/api/v1/logs/promote_paths`] as const;
};
export const getListPromotedAndIndexedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
>;
export type ListPromotedAndIndexedPathsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export function useListPromotedAndIndexedPaths<
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Promote and index paths
*/
export const invalidateListPromotedAndIndexedPaths = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const handlePromoteAndIndexPaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/logs/promote_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getHandlePromoteAndIndexPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['handlePromoteAndIndexPaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return handlePromoteAndIndexPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
>;
export type HandlePromoteAndIndexPathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type HandlePromoteAndIndexPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export const useHandlePromoteAndIndexPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
};

View File

@@ -1,238 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
ListPromotedPaths200,
ListPromotedPathsPathParameters,
PromotePathsPathParameters,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint lists the promoted paths of a JSON column. The promotion domain is identified by the telemetry_signal and context path variables, e.g. traces/attribute.
* @summary List promoted paths
*/
export const listPromotedPaths = (
{ telemetrySignal, context }: ListPromotedPathsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListPromotedPaths200>({
url: `/api/v1/promote_paths/${telemetrySignal}/${context}`,
method: 'GET',
signal,
});
};
export const getListPromotedPathsQueryKey = ({
telemetrySignal,
context,
}: ListPromotedPathsPathParameters) => {
return [`/api/v1/promote_paths/${telemetrySignal}/${context}`] as const;
};
export const getListPromotedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ telemetrySignal, context }: ListPromotedPathsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListPromotedPathsQueryKey({ telemetrySignal, context });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedPaths>>
> = ({ signal }) => listPromotedPaths({ telemetrySignal, context }, signal);
return {
queryKey,
queryFn,
enabled: !!(telemetrySignal && context),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedPaths>>
>;
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List promoted paths
*/
export function useListPromotedPaths<
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ telemetrySignal, context }: ListPromotedPathsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedPathsQueryOptions(
{ telemetrySignal, context },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List promoted paths
*/
export const invalidateListPromotedPaths = async (
queryClient: QueryClient,
{ telemetrySignal, context }: ListPromotedPathsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedPathsQueryKey({ telemetrySignal, context }) },
options,
);
return queryClient;
};
/**
* This endpoint promotes paths of a JSON column to its promoted column. The promotion domain is identified by the telemetry_signal and context path variables, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (
{ telemetrySignal, context }: PromotePathsPathParameters,
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/promote_paths/${telemetrySignal}/${context}`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getPromotePathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{
pathParams: PromotePathsPathParameters;
data?: BodyType<PromotetypesPromotePathDTO[] | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{
pathParams: PromotePathsPathParameters;
data?: BodyType<PromotetypesPromotePathDTO[] | null>;
},
TContext
> => {
const mutationKey = ['promotePaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof promotePaths>>,
{
pathParams: PromotePathsPathParameters;
data?: BodyType<PromotetypesPromotePathDTO[] | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return promotePaths(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PromotePathsMutationResult = NonNullable<
Awaited<ReturnType<typeof promotePaths>>
>;
export type PromotePathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote paths
*/
export const usePromotePaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{
pathParams: PromotePathsPathParameters;
data?: BodyType<PromotetypesPromotePathDTO[] | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{
pathParams: PromotePathsPathParameters;
data?: BodyType<PromotetypesPromotePathDTO[] | null>;
},
TContext
> => {
return useMutation(getPromotePathsMutationOptions(options));
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { CursorPosition } from './types';
import styles from './MarkdownEditor.module.scss';
interface EditorStatusBarProps {
cursor: CursorPosition;
length: number;
maxLength: number;
hint?: ReactNode;
}
function EditorStatusBar({
cursor,
length,
maxLength,
hint,
}: EditorStatusBarProps): JSX.Element {
const isOverLimit = length > maxLength;
return (
<div className={styles.statusBar} data-testid="markdown-editor-status">
<Typography.Text className={styles.statusPosition}>
{`Ln ${cursor.line}, Col ${cursor.column}`}
<span className={styles.statusSeparator}>·</span>
<span
className={cx(styles.statusCount, {
[styles.statusCountOverLimit]: isOverLimit,
})}
data-testid="markdown-editor-char-count"
>
{isOverLimit ? `${length} / ${maxLength} chars` : `${length} chars`}
</span>
</Typography.Text>
{hint && (
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
)}
</div>
);
}
export default EditorStatusBar;

View File

@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import {
Bold,
CodeXml,
Heading,
Italic,
Link,
List,
ListOrdered,
Table,
Type,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import InsertVariableMenu from './InsertVariableMenu';
import MarkdownHelp from './MarkdownHelp';
import type { EditorCommand, EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
const COMMAND_ICONS: Record<string, ReactNode> = {
heading: <Heading size={14} />,
bold: <Bold size={14} />,
italic: <Italic size={14} />,
'bulleted-list': <List size={14} />,
'numbered-list': <ListOrdered size={14} />,
link: <Link size={14} />,
code: <CodeXml size={14} />,
table: <Table size={14} />,
};
interface EditorToolbarProps {
formatLabel: string;
commands: EditorCommand[];
onRunCommand: (command: EditorCommand) => void;
variables: EditorVariable[];
onInsertVariable: (name: string) => void;
disabled: boolean;
extra?: ReactNode;
}
function EditorToolbar({
formatLabel,
commands,
onRunCommand,
variables,
onInsertVariable,
disabled,
extra,
}: EditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
<span className={styles.formatChip}>
<Type size={14} />
<Typography.Text className={styles.formatLabel}>
{formatLabel}
</Typography.Text>
</span>
<span className={styles.toolbarDivider} />
<div className={styles.commands}>
{commands.map((command) => (
<TooltipSimple key={command.id} title={command.label}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
disabled={disabled}
aria-label={command.label}
data-testid={`markdown-command-${command.id}`}
onClick={(): void => onRunCommand(command)}
>
{COMMAND_ICONS[command.id]}
</Button>
</TooltipSimple>
))}
</div>
<div className={styles.toolbarEnd}>
{extra}
<InsertVariableMenu
variables={variables}
onSelect={onInsertVariable}
disabled={disabled}
/>
<MarkdownHelp />
</div>
</div>
);
}
export default EditorToolbar;

View File

@@ -0,0 +1,91 @@
import { useMemo, useState } from 'react';
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
interface InsertVariableMenuProps {
variables: EditorVariable[];
/** Receives the variable name; the caller decides the token syntax. */
onSelect: (name: string) => void;
disabled: boolean;
}
function toMenuItems(
variables: EditorVariable[],
onSelect: (name: string) => void,
): MenuItem[] {
return variables.map((variable) => ({
key: variable.name,
label: (
<span
className={styles.variableRow}
data-testid={`markdown-variable-${variable.name}`}
>
<span className={styles.variableName}>{`$${variable.name}`}</span>
{variable.badge && (
<span className={styles.variableBadge}>{variable.badge}</span>
)}
</span>
),
onClick: (): void => onSelect(variable.name),
}));
}
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
function InsertVariableMenu({
variables,
onSelect,
disabled,
}: InsertVariableMenuProps): JSX.Element | null {
const [search, setSearch] = useState('');
const matches = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
: variables;
}, [variables, search]);
const items = useMemo(
() => toMenuItems(matches, onSelect),
[matches, onSelect],
);
if (variables.length === 0) {
return null;
}
return (
<DropdownMenuSimple
className={styles.variableMenu}
menu={{
items,
search: {
placeholder: 'Search variables',
searchIcon: <Search size={14} />,
onSearchChange: setSearch,
},
}}
>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
disabled={disabled}
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
suffix={<ChevronDown size={14} />}
className={styles.insertVariable}
data-testid="markdown-insert-variable"
>
Insert variable
</Button>
</DropdownMenuSimple>
);
}
export default InsertVariableMenu;

View File

@@ -0,0 +1,253 @@
@use '../../styles/scrollbar' as *;
.container {
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
--md-syntax-heading: var(--text-vanilla-100);
--md-syntax-strong: var(--text-vanilla-100);
--md-syntax-emphasis: var(--text-vanilla-300);
--md-syntax-quote: var(--text-vanilla-400);
--md-syntax-marker: var(--text-robin-300);
--md-syntax-code: var(--text-forest-400);
--md-syntax-link: var(--text-robin-400);
--md-syntax-variable: var(--text-amber-400);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--l1-background);
}
:global(body.lightMode) .container {
--md-syntax-heading: var(--text-ink-400);
--md-syntax-strong: var(--text-ink-400);
--md-syntax-emphasis: var(--text-ink-200);
--md-syntax-quote: var(--text-neutral-light-100);
--md-syntax-marker: var(--text-robin-500);
--md-syntax-code: var(--text-forest-700);
--md-syntax-link: var(--text-robin-500);
--md-syntax-variable: var(--text-sienna-500);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
}
.formatChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--text-sienna-400);
}
.formatLabel {
font-size: 12px;
font-weight: 500;
color: var(--l1-foreground);
}
.toolbarDivider {
width: 1px;
height: 16px;
flex-shrink: 0;
background: var(--l1-border);
}
.commands {
display: flex;
align-items: center;
gap: 2px;
}
.toolbarEnd {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.insertVariable {
white-space: nowrap;
}
.insertVariableIcon {
color: var(--text-amber-400);
}
// The ui library's dropdown assumes a global border-box reset this app doesn't
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
// `width: 100%` + padding, so in the portal they lay out content-box and
// overflow the popup by the padding — clipping the flush-right badge.
.variableMenu,
.variableMenu * {
box-sizing: border-box;
}
.variableMenu {
width: 320px;
}
// Shrinkable, so a clamped popup truncates the name instead of clipping the
// badge at the content's `overflow: hidden` edge.
.variableRow {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.variableName {
font-family: var(--font-family-sf-mono);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.variableBadge {
flex-shrink: 0;
margin-left: auto;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
border-radius: 4px;
font-family: var(--font-family-sf-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-amber-400);
}
.editorArea {
flex: 1;
min-height: 0;
overflow: hidden;
}
.codeMirror {
height: 100%;
font-family: var(--font-family-sf-mono);
font-size: 13px;
:global(.cm-editor) {
height: 100%;
background: transparent;
}
:global(.cm-editor.cm-focused) {
outline: none;
}
:global(.cm-gutters) {
background: transparent;
border-right: none;
color: var(--text-neutral-dark-200);
}
:global(.cm-scroller) {
line-height: 20px;
padding: 0 12px;
@include custom-scrollbar;
}
:global(.cm-content) {
padding: 8px 0;
}
}
.statusBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 6px 12px;
border-top: 1px solid var(--l1-border);
}
.statusPosition {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.statusSeparator {
color: var(--l1-border);
}
.statusCount {
color: inherit;
}
.statusCountOverLimit {
color: var(--text-cherry-400);
font-weight: 600;
}
.statusHint {
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.helpContent {
width: 280px;
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.helpTitle {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-neutral-dark-200);
}
.helpList {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
.helpRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
dt {
margin: 0;
code {
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-forest-400);
}
}
dd {
margin: 0;
font-size: 11px;
color: var(--text-neutral-dark-200);
}
}
// The help popover portals out of `.container`, so it can't inherit its tokens.
:global(body.lightMode) .helpRow dt code {
color: var(--text-forest-700);
}

View File

@@ -0,0 +1,245 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, {
type BasicSetupOptions,
EditorView,
type ViewUpdate,
} from '@uiw/react-codemirror';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
import EditorStatusBar from './EditorStatusBar';
import EditorToolbar from './EditorToolbar';
import { applyTransform, replaceDocument } from './editorDocument';
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
import { markdownHighlight } from './markdownHighlight';
import type {
CursorPosition,
EditorCommand,
EditorTransform,
EditorVariable,
} from './types';
import styles from './MarkdownEditor.module.scss';
/** What the status bar reports. */
type DocumentStatus = CursorPosition & { length: number };
// No language grammar is loaded, so bracket/indent/completion behaviour would only
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
const BASIC_SETUP: BasicSetupOptions = {
lineNumbers: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
foldGutter: false,
autocompletion: false,
bracketMatching: false,
closeBrackets: false,
indentOnInput: false,
syntaxHighlighting: false,
highlightSelectionMatches: false,
rectangularSelection: false,
crosshairCursor: false,
searchKeymap: false,
foldKeymap: false,
lintKeymap: false,
completionKeymap: false,
closeBracketsKeymap: false,
};
const EMPTY_VARIABLES: EditorVariable[] = [];
export interface MarkdownEditorProps {
/** Seeds the document; replaced only from outside. See the sync effect. */
value: string;
onChange: (value: string) => void;
/** Offered by the "Insert variable" menu; the button disables when empty. */
variables?: EditorVariable[];
/** What the character counter reports against. */
maxLength?: number;
placeholder?: string;
readOnly?: boolean;
/** Shown on the toolbar chip. */
formatLabel?: string;
/** Rendered before the "Insert variable" menu. */
toolbarExtra?: ReactNode;
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
statusHint?: ReactNode;
autoFocus?: boolean;
className?: string;
testId?: string;
}
/**
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
* the body, so the preview surface and its sanitisation stay the caller's concern.
*/
function MarkdownEditor({
value,
onChange,
variables = EMPTY_VARIABLES,
maxLength = MARKDOWN_MAX_LENGTH,
placeholder = 'Write Markdown…',
readOnly = false,
formatLabel = 'Markdown',
toolbarExtra,
statusHint,
autoFocus = false,
className,
testId = 'markdown-editor',
}: MarkdownEditorProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const viewRef = useRef<EditorView | null>(null);
// Set while a programmatic replacement is in flight, so the caller isn't told
// about a change it asked for. `dispatch` runs listeners synchronously, so the
// window is exactly one call.
const isSyncingRef = useRef(false);
const previousValueRef = useRef(value);
const hasSeededRef = useRef(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const [status, setStatus] = useState<DocumentStatus>(() => ({
line: 1,
column: 1,
length: value.length,
}));
const syncDocument = useCallback((view: EditorView, next: string): void => {
isSyncingRef.current = true;
replaceDocument(view, next);
isSyncingRef.current = false;
}, []);
const onCreateEditor = useCallback((view: EditorView): void => {
viewRef.current = view;
setIsEditorReady(true);
}, []);
/**
* Seeds the document, then applies external replacements — nothing else. Keeping
* keystrokes out of this round-trip is what stops a stale `value` from replacing
* the document and resetting the caret when typing outpaces React.
*
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
* `''` and reconciles against it once the view exists, wiping anything written
* before that. `isEditorReady` puts this effect after that pass, since a parent's
* effects flush after its children's.
*
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
* applied over the author.
*/
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const previous = previousValueRef.current;
previousValueRef.current = value;
const isSeeding = !hasSeededRef.current;
hasSeededRef.current = true;
if (!isSeeding && (value === previous || view.hasFocus)) {
return;
}
if (view.state.doc.toString() !== value) {
syncDocument(view, value);
}
}, [value, isEditorReady, syncDocument]);
const handleChange = useCallback(
(next: string): void => {
if (!isSyncingRef.current) {
onChange(next);
}
},
[onChange],
);
const runTransform = useCallback((transform: EditorTransform): void => {
const view = viewRef.current;
if (view) {
applyTransform(view, transform);
}
}, []);
const onRunCommand = useCallback(
(command: EditorCommand): void => runTransform(command.run),
[runTransform],
);
const onInsertVariable = useCallback(
(name: string): void =>
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
[runTransform],
);
const extensions = useMemo(
() => [markdownHighlight(), EditorView.lineWrapping],
[],
);
// From the document, not `value`: the caller may debounce or drop a change, and
// the counter has to match what the author sees.
const onUpdate = useCallback((update: ViewUpdate): void => {
if (!update.selectionSet && !update.docChanged) {
return;
}
const { head } = update.state.selection.main;
const line = update.state.doc.lineAt(head);
setStatus({
line: line.number,
column: head - line.from + 1,
length: update.state.doc.length,
});
}, []);
return (
<div className={cx(styles.container, className)} data-testid={testId}>
<EditorToolbar
formatLabel={formatLabel}
commands={MARKDOWN_COMMANDS}
onRunCommand={onRunCommand}
variables={variables}
onInsertVariable={onInsertVariable}
disabled={readOnly}
extra={toolbarExtra}
/>
<div className={styles.editorArea}>
<CodeMirror
className={styles.codeMirror}
// No `value`: passing it re-enables the wrapper's own reconciliation,
// and with it the caret reset.
onCreateEditor={onCreateEditor}
onChange={handleChange}
onUpdate={onUpdate}
theme={isDarkMode ? copilot : githubLight}
basicSetup={BASIC_SETUP}
placeholder={placeholder}
editable={!readOnly}
readOnly={readOnly}
indentWithTab={false}
autoFocus={autoFocus}
extensions={extensions}
height="100%"
/>
</div>
<EditorStatusBar
cursor={status}
length={status.length}
maxLength={maxLength}
hint={statusHint}
/>
</div>
);
}
export default MarkdownEditor;

View File

@@ -0,0 +1,44 @@
import { CircleHelp } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import { MARKDOWN_HELP_ITEMS } from './constants';
import styles from './MarkdownEditor.module.scss';
function MarkdownHelp(): JSX.Element {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label="Markdown syntax help"
data-testid="markdown-help-trigger"
>
<CircleHelp size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className={styles.helpContent}>
<Typography.Text className={styles.helpTitle}>
Markdown syntax
</Typography.Text>
<dl className={styles.helpList}>
{MARKDOWN_HELP_ITEMS.map((item) => (
<div key={item.syntax} className={styles.helpRow}>
<dt>
<code>{item.syntax}</code>
</dt>
<dd>{item.label}</dd>
</div>
))}
</dl>
</PopoverContent>
</Popover>
);
}
export default MarkdownHelp;

View File

@@ -0,0 +1,270 @@
import { useCallback, useRef, useState } from 'react';
import { EditorView } from '@uiw/react-codemirror';
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import MarkdownEditor from '../MarkdownEditor';
import type { EditorVariable } from '../types';
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => true,
}));
const VARIABLES: EditorVariable[] = [
{ name: 'environment', badge: 'QUERY' },
{ name: 'service', badge: 'CUSTOM' },
];
/** A caller whose state trails the editor by one keystroke. */
function LaggingHarness(): JSX.Element {
const [value, setValue] = useState('');
const previousRef = useRef('');
const onChange = useCallback((next: string): void => {
setValue(previousRef.current);
previousRef.current = next;
}, []);
return <MarkdownEditor value={value} onChange={onChange} />;
}
/** Pushes a replacement in from outside the editor. */
function ExternalHarness(): JSX.Element {
const [value, setValue] = useState('before');
return (
<>
<button type="button" onClick={(): void => setValue('after')}>
push
</button>
<MarkdownEditor value={value} onChange={setValue} />
</>
);
}
function Harness({
initialValue = '',
maxLength,
variables = VARIABLES,
}: {
initialValue?: string;
maxLength?: number;
variables?: EditorVariable[];
}): JSX.Element {
const [value, setValue] = useState(initialValue);
return (
<MarkdownEditor
value={value}
onChange={setValue}
variables={variables}
maxLength={maxLength}
statusHint="Preview updates as you type"
/>
);
}
const getView = (): EditorView => {
const dom = document.querySelector('.cm-editor');
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
if (!view) {
throw new Error('editor view not mounted');
}
return view;
};
const select = (from: number, to: number): void => {
act(() => {
getView().dispatch({ selection: { anchor: from, head: to } });
});
};
const documentText = (): string => getView().state.doc.toString();
describe('MarkdownEditor', () => {
it('reports the caret position and character count', async () => {
render(<Harness initialValue={'one\ntwo'} />);
select(5, 5);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
'Ln 2, Col 2',
);
});
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'7 chars',
);
});
it('flags a body over the character cap', async () => {
render(<Harness initialValue="123456" maxLength={5} />);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'6 / 5 chars',
);
});
});
it('applies a toolbar command to the selection', async () => {
render(<Harness initialValue="a word b" />);
select(2, 6);
await userEvent.click(screen.getByTestId('markdown-command-bold'));
await waitFor(() => {
expect(documentText()).toBe('a **word** b');
});
});
it('inserts a variable token at the caret', async () => {
render(<Harness initialValue="env: " />);
select(5, 5);
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
// The row shows the name and kind badge.
const row = await screen.findByTestId('markdown-variable-environment');
expect(row).toHaveTextContent('$environment');
expect(row).toHaveTextContent('QUERY');
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
// CodeMirror mocks stub out.
fireEvent.click(row);
await waitFor(() => {
expect(documentText()).toBe('env: $environment');
});
});
it('colours Markdown syntax and variable tokens in the source', async () => {
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
await waitFor(() => {
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
});
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
'{{team}}',
);
});
describe('uncontrolled document', () => {
const type = (at: number, text: string): void => {
act(() => {
getView().dispatch({
changes: { from: at, insert: text },
selection: { anchor: at + text.length },
});
});
};
const focusEditor = (): void => {
act(() => {
getView().focus();
});
};
it('keeps the document and caret while the caller lags behind the typing', () => {
render(<LaggingHarness />);
focusEditor();
type(0, 'a');
type(1, 'b');
type(2, 'c');
expect(documentText()).toBe('abc');
expect(getView().state.selection.main.head).toBe(3);
});
it('reports every keystroke to the caller', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="ab" onChange={onChange} />);
type(2, 'c');
expect(onChange).toHaveBeenLastCalledWith('abc');
});
it('does not report the seed back as a change', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="seeded" onChange={onChange} />);
expect(documentText()).toBe('seeded');
expect(onChange).not.toHaveBeenCalled();
});
it('applies an external replacement while the editor is unfocused', async () => {
render(<ExternalHarness />);
await userEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('after');
});
it('ignores a replacement that arrives while the author is still typing', () => {
render(<ExternalHarness />);
focusEditor();
// fireEvent: a real click would blur the editor first. This covers an update
// arriving on its own, while the author is still in the document.
fireEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('before');
});
it('counts characters from the document, not from the lagging value', async () => {
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
type(2, 'cde');
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'5 chars',
);
});
});
});
it('offers both list kinds in the toolbar', () => {
render(<Harness />);
expect(
screen.getByTestId('markdown-command-bulleted-list'),
).toBeInTheDocument();
expect(
screen.getByTestId('markdown-command-numbered-list'),
).toBeInTheDocument();
});
it('disables authoring affordances when read-only', () => {
render(
<MarkdownEditor
value="body"
onChange={jest.fn()}
variables={VARIABLES}
readOnly
/>,
);
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
});
it('hides the insert-variable control when none are available', () => {
render(<Harness variables={[]} />);
expect(
screen.queryByTestId('markdown-insert-variable'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,258 @@
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
import type { EditorSnapshot, EditorTransform } from '../types';
const commandById = (id: string): EditorTransform => {
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
if (!command) {
throw new Error(`unknown command: ${id}`);
}
return command.run;
};
const heading = commandById('heading');
const bold = commandById('bold');
const italic = commandById('italic');
const bulletedList = commandById('bulleted-list');
const numberedList = commandById('numbered-list');
const link = commandById('link');
const code = commandById('code');
const table = commandById('table');
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
const snapshot = (marked: string): EditorSnapshot => {
if (marked.includes('|')) {
const caret = marked.indexOf('|');
return {
text: marked.replace('|', ''),
selectionStart: caret,
selectionEnd: caret,
};
}
const start = marked.indexOf('[');
const end = marked.indexOf(']') - 1;
return {
text: marked.replace('[', '').replace(']', ''),
selectionStart: start,
selectionEnd: end,
};
};
const selectionOf = (result: EditorSnapshot): string =>
result.text.slice(result.selectionStart, result.selectionEnd);
describe('heading', () => {
it('prefixes the caret line and keeps the caret on the same character', () => {
const result = heading(snapshot('Chec|kout'));
expect(result.text).toBe('## Checkout');
expect(result.selectionStart).toBe(7);
});
it('strips the prefix when every selected line already has one', () => {
const result = heading({
text: '## one\n### two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
it('adds the prefix when only some selected lines have one', () => {
const result = heading({
text: '## one\ntwo',
selectionStart: 0,
selectionEnd: 10,
});
expect(result.text).toBe('## ## one\n## two');
});
it('does not pull in the line after a selection ending on a line break', () => {
const result = heading({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 4,
});
expect(result.text).toBe('## one\ntwo');
});
});
describe('bulleted list', () => {
it('bullets every line of a multi-line selection', () => {
const result = bulletedList({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('- one\n- two');
expect(selectionOf(result)).toBe('- one\n- two');
});
it('unbullets a list written with a different marker', () => {
const result = bulletedList({
text: '* one\n+ two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('numbered list', () => {
it('numbers each line of the selection in order', () => {
const result = numberedList({
text: 'one\ntwo\nthree',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('1. one\n2. two\n3. three');
});
it('unnumbers a list whose numbering is not sequential', () => {
const result = numberedList({
text: '1. one\n5. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('switching between list kinds', () => {
it('converts bullets to numbers rather than marking them twice', () => {
const result = numberedList({
text: '- one\n- two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('1. one\n2. two');
});
it('converts numbers to bullets', () => {
const result = bulletedList({
text: '1. one\n2. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('- one\n- two');
});
it('keeps indentation so nested items stay nested', () => {
const result = numberedList({
text: 'one\n - nested',
selectionStart: 0,
selectionEnd: 16,
});
expect(result.text).toBe('1. one\n 2. nested');
});
});
describe('bold and italic', () => {
it('wraps the selection and keeps the original text selected', () => {
const result = bold(snapshot('a [word] b'));
expect(result.text).toBe('a **word** b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit inside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 2,
selectionEnd: 10,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit just outside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 4,
selectionEnd: 8,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('leaves the caret between the markers when nothing is selected', () => {
const result = italic(snapshot('a |b'));
expect(result.text).toBe('a __b');
expect(result.selectionStart).toBe(3);
expect(result.selectionEnd).toBe(3);
});
it('does not mistake a leading document boundary for a marker', () => {
const result = bold(snapshot('[word] tail'));
expect(result.text).toBe('**word** tail');
});
});
describe('link', () => {
it('selects the url when the label came from the selection', () => {
const result = link(snapshot('see [docs] now'));
expect(result.text).toBe('see [docs](https://) now');
expect(selectionOf(result)).toBe('https://');
});
it('selects the label placeholder when nothing was selected', () => {
const result = link(snapshot('see |'));
expect(result.text).toBe('see [text](https://)');
expect(selectionOf(result)).toBe('text');
});
});
describe('code', () => {
it('uses backticks for a single-line selection', () => {
const result = code(snapshot('run [npm] here'));
expect(result.text).toBe('run `npm` here');
});
it('fences a multi-line selection and selects its content', () => {
const result = code({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('```\none\ntwo\n```');
expect(selectionOf(result)).toBe('one\ntwo');
});
});
describe('table', () => {
it('starts the skeleton on its own line and selects the first header cell', () => {
const result = table(snapshot('intro|'));
expect(result.text).toBe(
'intro\n| Column | Column |\n| --- | --- |\n| | |',
);
expect(selectionOf(result)).toBe('Column');
});
});
describe('insertText', () => {
it('replaces the selection and leaves the caret after the insertion', () => {
const result = insertText(snapshot('env is [old]'), '{{env}}');
expect(result.text).toBe('env is {{env}}');
expect(result.selectionStart).toBe(14);
expect(result.selectionEnd).toBe(14);
});
});

View File

@@ -0,0 +1,24 @@
// The body is persisted inline in the dashboard JSON, so its length is capped.
export const MARKDOWN_MAX_LENGTH = 16000;
/** The canonical syntax; the renderer resolves the other three too. */
export const formatVariableToken = (name: string): string => `$${name}`;
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
// First: consecutive lines joining into one paragraph is the CommonMark rule
// authors trip over before any of the formatting syntax.
{ syntax: 'blank line', label: 'New paragraph' },
{ syntax: '2 spaces + ⏎', label: 'Line break' },
{ syntax: '# Heading', label: 'Heading (16 #)' },
{ syntax: '**bold**', label: 'Bold' },
{ syntax: '_italic_', label: 'Italic' },
{ syntax: '- item', label: 'Bulleted list' },
{ syntax: '1. item', label: 'Numbered list' },
{ syntax: '- [ ] task', label: 'Task list' },
{ syntax: '[label](url)', label: 'Link' },
{ syntax: '![alt](url)', label: 'Image' },
{ syntax: '`code`', label: 'Inline code' },
{ syntax: '```lang', label: 'Code block' },
{ syntax: '> quote', label: 'Blockquote' },
{ syntax: '| a | b |', label: 'Table' },
];

View File

@@ -0,0 +1,69 @@
import { EditorView } from '@uiw/react-codemirror';
import type { EditorSnapshot, EditorTransform } from './types';
// Narrows a whole-document replacement to the range that changed, so a toolbar
// action doesn't invalidate the document's decorations or scroll position.
function toChangeSpec(
previous: string,
next: string,
): { from: number; to: number; insert: string } | null {
if (previous === next) {
return null;
}
const shorter = Math.min(previous.length, next.length);
let start = 0;
while (start < shorter && previous[start] === next[start]) {
start += 1;
}
let previousEnd = previous.length;
let nextEnd = next.length;
while (
previousEnd > start &&
nextEnd > start &&
previous[previousEnd - 1] === next[nextEnd - 1]
) {
previousEnd -= 1;
nextEnd -= 1;
}
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
}
export function readSnapshot(view: EditorView): EditorSnapshot {
const range = view.state.selection.main;
return {
text: view.state.doc.toString(),
selectionStart: range.from,
selectionEnd: range.to,
};
}
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
export function applyTransform(
view: EditorView,
transform: EditorTransform,
): boolean {
if (view.state.readOnly) {
return false;
}
const next = transform(readSnapshot(view));
const changes = toChangeSpec(view.state.doc.toString(), next.text);
view.dispatch({
...(changes ? { changes } : {}),
selection: { anchor: next.selectionStart, head: next.selectionEnd },
scrollIntoView: true,
});
view.focus();
return true;
}
/** Replaces the whole document, for seeding and external replacements. */
export function replaceDocument(view: EditorView, next: string): void {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: next },
});
}

View File

@@ -0,0 +1,269 @@
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
const BOLD_MARKER = '**';
const ITALIC_MARKER = '_';
const INLINE_CODE_MARKER = '`';
const CODE_FENCE = '```';
const HEADING_PREFIX = '## ';
const BULLET_MARKER = '- ';
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
// Either kind of marker, matched after the indent has been split off.
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
const INDENT_PATTERN = /^[ \t]*/;
const LINK_LABEL_PLACEHOLDER = 'text';
const LINK_URL_PLACEHOLDER = 'https://';
const TABLE_CELL_PLACEHOLDER = 'Column';
const TABLE_SNIPPET = [
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
'| --- | --- |',
'| | |',
].join('\n');
interface LineRange {
start: number;
end: number;
}
// A selection ending exactly on a line break stops there rather than pulling in
// the next line, so "select the line, hit list" doesn't bullet the line below too.
function expandToLines(text: string, from: number, to: number): LineRange {
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
const breakAfter = text.indexOf('\n', end);
return {
start: breakBefore + 1,
end: breakAfter === -1 ? text.length : breakAfter,
};
}
/** Pads `block` so it starts and ends on its own line. */
function replaceWithBlock(
snapshot: EditorSnapshot,
block: string,
): { text: string; blockStart: number } {
const { text, selectionStart, selectionEnd } = snapshot;
const before = text.slice(0, selectionStart);
const after = text.slice(selectionEnd);
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
return {
text: before + lead + block + trail + after,
blockStart: before.length + lead.length,
};
}
/** Rewrites every line the selection touches. */
function replaceLines(
snapshot: EditorSnapshot,
mapLines: (lines: string[]) => string[],
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
const lines = text.slice(start, end).split('\n');
const nextLines = mapLines(lines);
const block = nextLines.join('\n');
const nextText = text.slice(0, start) + block + text.slice(end);
if (selectionStart !== selectionEnd) {
return {
text: nextText,
selectionStart: start,
selectionEnd: start + block.length,
};
}
// Caret-only: the range covers one line, so shift by that line's delta.
const shifted = selectionStart + nextLines[0].length - lines[0].length;
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
return { text: nextText, selectionStart: caret, selectionEnd: caret };
}
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line) =>
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
);
});
}
/**
* Toggles this kind of list marker. A line carrying the *other* kind is converted
* rather than marked twice, and indentation is preserved so nesting survives.
* `markerAt` takes the line's position, which is what lets an ordered list number.
*/
function toggleList(
pattern: RegExp,
markerAt: (index: number) => string,
): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line, index) => {
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
return isApplied
? `${indent}${body}`
: `${indent}${markerAt(index)}${body}`;
});
});
}
/**
* Unwraps when the markers are already there, whether they sit inside the selection
* (`**bold**` selected whole) or just outside it (only `bold` selected).
*/
function toggleWrap(marker: string): EditorTransform {
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const width = marker.length;
if (
selected.length >= width * 2 &&
selected.startsWith(marker) &&
selected.endsWith(marker)
) {
const inner = selected.slice(width, -width);
return {
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
selectionStart,
selectionEnd: selectionStart + inner.length,
};
}
if (
selectionStart >= width &&
text.slice(selectionStart - width, selectionStart) === marker &&
text.slice(selectionEnd, selectionEnd + width) === marker
) {
return {
text:
text.slice(0, selectionStart - width) +
selected +
text.slice(selectionEnd + width),
selectionStart: selectionStart - width,
selectionEnd: selectionStart - width + selected.length,
};
}
return {
text:
text.slice(0, selectionStart) +
marker +
selected +
marker +
text.slice(selectionEnd),
selectionStart: selectionStart + width,
selectionEnd: selectionStart + width + selected.length,
};
};
}
/** Lands the selection on whichever half is still a placeholder. */
const insertLink: EditorTransform = ({
text,
selectionStart,
selectionEnd,
}): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const label = selected || LINK_LABEL_PLACEHOLDER;
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
const nextText =
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
// `[` + label + `](` is label.length + 3 characters.
const target = selected
? {
from: selectionStart + label.length + 3,
length: LINK_URL_PLACEHOLDER.length,
}
: { from: selectionStart + 1, length: label.length };
return {
text: nextText,
selectionStart: target.from,
selectionEnd: target.from + target.length,
};
};
/** Backticks for a single-line selection, a fence for a multi-line one. */
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
const { text, selectionStart, selectionEnd } = snapshot;
const selected = text.slice(selectionStart, selectionEnd);
if (!selected.includes('\n')) {
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
}
const { text: nextText, blockStart } = replaceWithBlock(
snapshot,
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
);
const contentStart = blockStart + CODE_FENCE.length + 1;
return {
text: nextText,
selectionStart: contentStart,
selectionEnd: contentStart + selected.length,
};
};
/** Selects the first header cell, for immediate typing. */
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
return {
text,
selectionStart: firstCell,
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
};
};
/** Replaces the selection and leaves the caret after the insertion. */
export function insertText(
snapshot: EditorSnapshot,
value: string,
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const caret = selectionStart + value.length;
return {
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
selectionStart: caret,
selectionEnd: caret,
};
}
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
export const MARKDOWN_COMMANDS: EditorCommand[] = [
{
id: 'heading',
label: 'Heading',
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
},
{
id: 'bold',
label: 'Bold',
run: toggleWrap(BOLD_MARKER),
},
{
id: 'italic',
label: 'Italic',
run: toggleWrap(ITALIC_MARKER),
},
{
id: 'bulleted-list',
label: 'Bulleted list',
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
},
{
id: 'numbered-list',
label: 'Numbered list',
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
},
{ id: 'link', label: 'Link', run: insertLink },
{ id: 'code', label: 'Code', run: insertCode },
{ id: 'table', label: 'Table', run: insertTable },
];

View File

@@ -0,0 +1,149 @@
import type { Extension, Line, Range } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const QUOTE_PATTERN = /^ {0,3}> ?/;
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
/**
* Convention: capture group 1, when present, is a left guard the token excludes —
* the token runs from the end of that group to the end of the match. Lookbehind is
* avoided for Safari compatibility, so guards are captured rather than asserted.
*/
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
{
// The four variable syntaxes a dashboard body may carry.
pattern:
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
className: 'cm-md-variable',
},
];
const MARKS = {
heading: Decoration.mark({ class: 'cm-md-heading' }),
quote: Decoration.mark({ class: 'cm-md-quote' }),
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
code: Decoration.mark({ class: 'cm-md-code' }),
} as const;
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
pattern,
mark: Decoration.mark({ class: className }),
}));
function pushInlineMarks(
lineText: string,
lineFrom: number,
ranges: Range<Decoration>[],
): void {
INLINE_MARKS.forEach(({ pattern, mark }) => {
pattern.lastIndex = 0;
let match = pattern.exec(lineText);
while (match !== null) {
const guardLength = match[1]?.length ?? 0;
const from = lineFrom + match.index + guardLength;
const to = lineFrom + match.index + match[0].length;
if (to > from) {
ranges.push(mark.range(from, to));
}
match = pattern.exec(lineText);
}
});
}
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
if (HEADING_PATTERN.test(line.text)) {
ranges.push(MARKS.heading.range(line.from, line.to));
return;
}
if (QUOTE_PATTERN.test(line.text)) {
ranges.push(MARKS.quote.range(line.from, line.to));
return;
}
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
if (listMarker) {
ranges.push(
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
);
}
}
// Scans the whole document rather than the viewport: fenced blocks opening above
// the visible range would otherwise be mis-detected. Bounded by the length cap.
function buildDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const ranges: Range<Decoration>[] = [];
let isInsideFence = false;
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
const line = doc.line(lineNumber);
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
if (isFenceDelimiter || isInsideFence) {
if (line.to > line.from) {
ranges.push(MARKS.code.range(line.from, line.to));
}
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
} else {
pushBlockMark(line, ranges);
pushInlineMarks(line.text, line.from, ranges);
}
}
return Decoration.set(ranges, true);
}
// Colours come from custom properties so the SCSS module owns light/dark.
const syntaxTheme = EditorView.theme({
'.cm-md-heading': {
color: 'var(--md-syntax-heading)',
fontWeight: '600',
},
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
'.cm-md-code': { color: 'var(--md-syntax-code)' },
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
'.cm-md-emphasis': {
color: 'var(--md-syntax-emphasis)',
fontStyle: 'italic',
},
'.cm-md-link': { color: 'var(--md-syntax-link)' },
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
});
const highlightPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: (plugin): DecorationSet => plugin.decorations },
);
/**
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
* colouring pass over a body the renderer parses for real.
*/
export function markdownHighlight(): Extension {
return [highlightPlugin, syntaxTheme];
}

View File

@@ -0,0 +1,27 @@
/** The value every editor command reads and returns. */
export interface EditorSnapshot {
text: string;
selectionStart: number;
selectionEnd: number;
}
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
export interface EditorVariable {
name: string;
/** Short tag for the variable's kind, e.g. "QUERY". */
badge?: string;
}
export interface EditorCommand {
id: string;
/** Accessible name and tooltip for the toolbar button. */
label: string;
run: EditorTransform;
}
/** 1-based, as the status bar reports it. */
export interface CursorPosition {
line: number;
column: number;
}

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

@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

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

@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
BAR: true,
PIE: false,
HISTOGRAM: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -14,6 +14,8 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.LIST]: ListPanelWrapper,
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

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

@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
text: 'Markdown body',
};
interface VariableImpactDialogProps {

View File

@@ -0,0 +1,49 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { buildVariableImpactPatch } from '../utils/variableImpactPatch';
import type { VariableUsage } from '../utils/variableUsages';
jest.mock('../variableAdapters', () => ({
formModelToDto: (model: unknown): unknown => model,
}));
const dashboard = {
spec: {
panels: {
runbook: {
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: 'env {{svc}}' } },
queries: [],
},
},
},
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const textUsage: VariableUsage = {
id: 'panel:runbook:0',
sourceType: 'panel',
sourceId: 'runbook',
sourceLabel: 'Runbook',
kind: 'text',
envelopeIndex: 0,
currentText: 'env {{svc}}',
resultingText: 'env {{zone}}',
};
describe('buildVariableImpactPatch — text panel bodies', () => {
it('replaces the plugin-spec text, never the (empty) queries', () => {
const ops = buildVariableImpactPatch(dashboard, [], [textUsage]);
const panelOps = ops.filter((op) => op.path.includes('/panels/'));
expect(panelOps).toStrictEqual([
{
op: 'replace',
path: '/spec/panels/runbook/spec/plugin/spec/text',
value: 'env {{zone}}',
},
]);
});
});

View File

@@ -45,6 +45,16 @@ function promqlPanel(name: string, query: string): unknown {
};
}
function textPanel(name: string, text: string): unknown {
return {
spec: {
display: { name },
plugin: { kind: 'signoz/TextPanel', spec: { text } },
queries: [],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
@@ -99,6 +109,38 @@ describe('findVariableUsages', () => {
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
describe('text panel bodies (TDD D5)', () => {
const textDash = dashboard(
{
runbook: textPanel(
'Runbook',
'env {{svc}} / {{.svc}} / [[svc]] / $svc / {{svcx}}',
),
unrelated: textPanel('Plain', 'no tokens here'),
},
[variable({ name: 'svc', type: 'QUERY' })],
);
it('finds the body usage and skips bodies without the token', () => {
const usages = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usages.map((u) => u.id)).toStrictEqual(['panel:runbook:0']);
expect(usages[0].kind).toBe('text');
expect(usages[0].sourceLabel).toBe('Runbook');
});
it('rewrites all four syntaxes on rename, leaving other names alone', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usage.resultingText).toBe(
'env {{zone}} / {{.zone}} / [[zone]] / $zone / {{svcx}}',
);
});
it('leaves the body for review on delete', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'delete');
expect(usage.resultingText).toBe(usage.currentText);
});
});
});
describe('findApplyUsages', () => {

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,16 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { isStaticPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
/**
* The markdown body of a static (query-less) panel, or null for query kinds.
* One localized cast: the plugin-spec union can't be narrowed by a dynamic kind.
*/
export function getTextPanelBody(
spec: DashboardtypesPanelSpecDTO | undefined,
): string | null {
if (!spec?.plugin || !isStaticPanelKind(spec.plugin.kind)) {
return null;
}
const { text } = spec.plugin.spec as { text?: string };
return typeof text === 'string' ? text : null;
}

View File

@@ -107,6 +107,18 @@ export function buildVariableImpactPatch(
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
// A static kind's usage edits its markdown body, not a query.
const textUsage = list.find((usage) => usage.kind === 'text');
if (textUsage) {
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/plugin/spec/text`,
value: textUsage.resultingText,
});
return;
}
if (!panel?.spec?.queries?.length) {
return;
}

View File

@@ -12,6 +12,7 @@ import {
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
/** The kind of query text a variable is referenced from. */
@@ -19,7 +20,8 @@ export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
| 'variable'
| 'text';
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
@@ -81,7 +83,7 @@ function computeResultingText(
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableFromExpression(text, variableName)
: text;
@@ -106,6 +108,31 @@ export function findVariableUsages(
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
// A static kind references variables from its body, not a query (TDD D5 —
// rename must rewrite text bodies too, or it silently orphans the tokens).
const textBody = getTextPanelBody(panel?.spec);
if (typeof textBody === 'string') {
if (textContainsVariableReference(textBody, variableName)) {
usages.push({
id: `panel:${panelId}:0`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: 'text',
envelopeIndex: 0,
currentText: textBody,
resultingText: computeResultingText(
'text',
textBody,
variableName,
mode,
newName,
),
});
}
return;
}
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;

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

@@ -5,10 +5,12 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
@@ -65,9 +67,16 @@ function ConfigPane({
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
// The header toggle belongs with the title and description it hides, so the kind's
// declaration still gates it but it renders above, out of the display options.
const headerSection = definition.sections.find(
(config) => config.kind === SectionKind.PanelHeader,
);
const sections = definition.sections.filter(
(config) => config.kind !== SectionKind.PanelHeader,
);
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.
@@ -104,6 +113,23 @@ function ConfigPane({
onChange={(e): void => setDisplayField('description', e.target.value)}
/>
</div>
{headerSection && (
<SectionSlot
bare
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
)}
</div>
{sections.length > 0 && (

View File

@@ -5,8 +5,18 @@ import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -34,6 +44,7 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']

View File

@@ -2,8 +2,8 @@ import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
@@ -27,17 +27,17 @@ export function usePanelTypeSelectItems({
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
kind,
queryType,
signal,
label,
label: displayName,
});
return {
value: panelKind,
label,
value: kind,
label: displayName,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,

View File

@@ -2,6 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
@@ -37,6 +38,12 @@ export function getPanelTypeDisabledReason({
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
// A kind that renders without a query pairs with anything — it declares no
// query types or signals, and the checks below would read that as "supports
// nothing" and disable it everywhere.
if (isStaticPanelKind(kind)) {
return undefined;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}

View File

@@ -16,6 +16,8 @@ type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
bare?: boolean;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
// Per-section header content; `trigger` expands the section and runs the editor's handler.
@@ -50,6 +52,7 @@ function SectionSlot({
config,
spec,
onChangeSpec,
bare,
legendSeries,
tableColumns,
signal,
@@ -110,6 +113,28 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const editorElement = (
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
);
if (bare) {
return editorElement;
}
return (
<SettingsSection
title={title}
@@ -118,21 +143,7 @@ function SectionSlot({
onOpenChange={setOpen}
headerSlot={headerSlot}
>
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
{editorElement}
</SettingsSection>
);
}

View File

@@ -23,6 +23,14 @@ jest.mock(
}),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '' } },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -93,6 +101,24 @@ describe('ConfigPane', () => {
);
});
// It hides the title strip, so it sits with the title rather than under the
// display options — and only a kind whose spec accepts `headerOptions` shows it.
it('renders the hide-header toggle among the Panel Details fields', () => {
renderConfigPane({ spec: textSpec() });
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});
it('omits the hide-header toggle for a kind that has no header options', () => {
renderConfigPane();
expect(screen.queryByTestId('panel-header-hide')).not.toBeInTheDocument();
});
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.

View File

@@ -0,0 +1,76 @@
.row {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
flex: none;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: none;
cursor: pointer;
position: relative;
// The input carries focus, so the ring is drawn on the swatch around it.
&:has(.input:focus-visible) {
outline: 2px solid var(--bg-robin-400);
outline-offset: 1px;
}
}
// The real control, sized to the swatch and invisible over it: clicks and focus
// land on the radio, the swatch is what the user sees.
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
box-shadow: 0 0 0 2px var(--bg-robin-500);
}
// Transparency has no colour to show, so it reads as the conventional checkerboard.
.checkerboard {
background-color: var(--l2-background);
background-image:
linear-gradient(
45deg,
var(--l2-border) 25%,
transparent 25%,
transparent 75%,
var(--l2-border) 75%
),
linear-gradient(
45deg,
var(--l2-border) 25%,
transparent 25%,
transparent 75%,
var(--l2-border) 75%
);
background-size: 8px 8px;
background-position:
0 0,
4px 4px;
}
.defaultSurface {
background: var(--l2-background);
}
.divider {
flex: none;
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--l2-border);
}

View File

@@ -0,0 +1,119 @@
import { Fragment } from 'react';
import { Check } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import {
TEXT_BACKGROUND_PAIRS,
TEXT_BACKGROUND_PRESETS,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import type {
PanelTheme,
TextBackgroundPreset,
TextBackgroundSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import styles from './BackgroundSwatches.module.scss';
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
/** Neither base swatch shows a colour, so its tooltip says what it does. */
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
none: 'Transparent — no card, border or title bar',
default: 'Default panel colour',
};
const OPTIONS: TextBackgroundSelection[] = [
TextBackgroundKind.None,
TextBackgroundKind.Default,
...TEXT_BACKGROUND_PRESETS,
];
const DIVIDER_AFTER = 1;
interface BackgroundSwatchesProps {
testId: string;
/** Names the group for assistive tech — the row carries no visible label. */
label: string;
/** `undefined` while a custom colour is active: no swatch is selected. */
value: TextBackgroundSelection | undefined;
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
theme: PanelTheme;
onChange: (value: TextBackgroundSelection) => void;
}
/**
* The Text panel's background choices as one radio group. Native radios sharing a
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
* are the platform's; each input is transparent and fills its swatch.
*/
function BackgroundSwatches({
testId,
label,
value,
theme,
onChange,
}: BackgroundSwatchesProps): JSX.Element {
return (
<div
className={styles.row}
role="radiogroup"
aria-label={label}
data-testid={testId}
>
{OPTIONS.map((option, index) => {
const isBase =
option === TextBackgroundKind.None ||
option === TextBackgroundKind.Default;
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
return (
<Fragment key={option}>
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
<label
className={cx(styles.swatch, {
[styles.checkerboard]: option === TextBackgroundKind.None,
[styles.defaultSurface]: option === TextBackgroundKind.Default,
[styles.selected]: option === value,
})}
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
data-testid={`${testId}-${option}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option}
checked={option === value}
aria-label={title}
onChange={(): void => onChange(option)}
/>
{option === value && <Check size={14} />}
</label>
</TooltipSimple>
{index === DIVIDER_AFTER && <span className={styles.divider} />}
</Fragment>
);
})}
</div>
);
}
export default BackgroundSwatches;

View File

@@ -0,0 +1,67 @@
.row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
text-align: left;
}
.active {
border-color: var(--bg-robin-500);
}
.chip {
flex: none;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
// No colour to show yet, so the chip advertises that it opens a picker.
.chipEmpty {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.label {
flex: 1;
font-size: 12px;
color: var(--l2-foreground);
}
.hex {
font-family: var(--font-family-sf-mono);
font-size: 12px;
color: var(--text-vanilla-400);
letter-spacing: 0.02em;
}
// Appended under the picker's own panel.
.contrast {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 4px 2px;
font-size: 12px;
color: var(--text-vanilla-400);
}
.warning {
color: var(--bg-amber-400);
}

View File

@@ -0,0 +1,91 @@
import type { ReactNode } from 'react';
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import {
contrastRatio,
inkForSurface,
MIN_CONTRAST_RATIO,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
import styles from './CustomBackgroundRow.module.scss';
const HEX_PLACEHOLDER = '#______';
/** What the picker opens on before a colour is chosen. */
const INITIAL_COLOR = '#3A2A63';
interface CustomBackgroundRowProps {
testId: string;
/** The stored hex while a custom colour is active; `undefined` otherwise. */
value: string | undefined;
onChange: (hex: string) => void;
}
/**
* The custom colour, as a row rather than a swatch: it opens a picker instead of
* committing a value in one click. The picker warns below the contrast floor but
* never blocks the choice.
*/
function CustomBackgroundRow({
testId,
value,
onChange,
}: CustomBackgroundRowProps): JSX.Element {
const color = value ?? INITIAL_COLOR;
const ratio = contrastRatio(inkForSurface(color), color);
const isLegible = ratio >= MIN_CONTRAST_RATIO;
const contrastMessage = isLegible
? `Contrast ${ratio.toFixed(1)}:1`
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
function renderPanel(panel: ReactNode): ReactNode {
return (
<>
{panel}
<div
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
data-testid={`${testId}-contrast`}
>
{!isLegible && <TriangleAlert size={12} />}
<span className="translate-safe">{contrastMessage}</span>
</div>
</>
);
}
return (
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
panelRender={renderPanel}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.row, { [styles.active]: value !== undefined })}
data-testid={testId}
>
<span
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
style={
value ? { background: value, color: inkForSurface(value) } : undefined
}
>
{value !== undefined && <Check size={14} />}
</span>
<Typography.Text className={styles.label}>Custom</Typography.Text>
<span className={cx(styles.hex, 'translate-safe')}>
{value ?? HEX_PLACEHOLDER}
</span>
<ChevronDown size={14} />
</button>
</ColorPicker>
);
}
export default CustomBackgroundRow;

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