Compare commits

...

10 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
354 changed files with 19590 additions and 2747 deletions

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,

View File

@@ -296,6 +296,17 @@ components:
- jsmops
- incidentio
type: string
AlertmanagertypesChannelListOrder:
enum:
- asc
- desc
type: string
AlertmanagertypesChannelListSort:
enum:
- updated_at
- created_at
- name
type: string
AlertmanagertypesChannelMSTeamsConfig:
properties:
sendResolved:
@@ -576,6 +587,43 @@ components:
wont_fix_resolution:
type: string
type: object
AlertmanagertypesListableNotificationChannel:
properties:
channels:
items:
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
type: array
total:
format: int64
type: integer
required:
- channels
- total
type: object
AlertmanagertypesListedNotificationChannel:
properties:
createdAt:
format: date-time
type: string
displayName:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
name:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- displayName
- kind
- createdAt
- updatedAt
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -942,6 +990,20 @@ components:
- timezone
- startTime
type: object
AlertmanagertypesTestableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
required:
- config
type: object
AlertmanagertypesUpdatableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
required:
- config
type: object
AuthtypesAttributeMapping:
properties:
email:
@@ -3474,6 +3536,11 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3831,6 +3898,7 @@ components:
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
@@ -3841,6 +3909,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3851,6 +3920,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3924,6 +3994,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
properties:
kind:
enum:
- signoz/TextPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
properties:
kind:
@@ -4215,6 +4297,37 @@ components:
- color
- columnName
type: object
DashboardtypesTextAlign:
enum:
- left
- center
- right
type: string
DashboardtypesTextMode:
enum:
- markdown
type: string
DashboardtypesTextPanelSpec:
properties:
headerOptions:
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
mode:
$ref: '#/components/schemas/DashboardtypesTextMode'
presentation:
$ref: '#/components/schemas/DashboardtypesTextPresentation'
text:
type: string
type: object
DashboardtypesTextPresentation:
properties:
background:
nullable: true
type: string
textAlign:
$ref: '#/components/schemas/DashboardtypesTextAlign'
verticalAlign:
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
@@ -4424,6 +4537,12 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:
@@ -7395,10 +7514,7 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
meta:
properties:
unit:
type: string
type: object
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
predictedSeries:
items:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
@@ -7413,12 +7529,51 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
type: object
Querybuildertypesv5Bucket:
Querybuildertypesv5AggregationMeta:
properties:
step:
format: double
type: number
buckets:
items:
format: double
type: number
type: array
unit:
type: string
type: object
Querybuildertypesv5BucketOptions:
discriminator:
mapping:
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
type: object
Querybuildertypesv5BucketOptionsLinear:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketOptionsLog:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketsKind:
enum:
- linear
- log
type: string
Querybuildertypesv5BuilderQuerySpec:
discriminator:
mapping:
@@ -7599,6 +7754,16 @@ components:
value:
type: string
type: object
Querybuildertypesv5LinearBucketsSpec:
properties:
maxValue:
format: double
type: number
numBuckets:
type: integer
required:
- maxValue
type: object
Querybuildertypesv5LogAggregation:
properties:
alias:
@@ -7606,6 +7771,12 @@ components:
expression:
type: string
type: object
Querybuildertypesv5LogBucketsSpec:
properties:
scale:
nullable: true
type: integer
type: object
Querybuildertypesv5MetricAggregation:
properties:
comparisonSpaceAggregationParam:
@@ -7686,6 +7857,8 @@ components:
type: object
Querybuildertypesv5QueryBuilderFormula:
properties:
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
disabled:
type: boolean
expression:
@@ -7716,6 +7889,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -7777,6 +7952,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -7838,6 +8015,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -8163,6 +8342,7 @@ components:
- raw
- raw_stream
- trace
- heatmap
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -8237,8 +8417,6 @@ components:
type: object
Querybuildertypesv5TimeSeriesValue:
properties:
bucket:
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
partial:
type: boolean
timestamp:
@@ -8349,6 +8527,8 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8392,6 +8572,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8497,6 +8679,7 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:
@@ -19720,6 +19903,86 @@ paths:
tags:
- metrics
/api/v2/notification_channels:
get:
deprecated: false
description: 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`).
operationId: ListNotificationChannels
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: kind
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
- in: query
name: sort
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesListableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:list
- tokenizer:
- notification-channel:list
summary: List notification channels
tags:
- channels
post:
deprecated: false
description: This endpoint creates a notification channel
@@ -19782,6 +20045,239 @@ paths:
summary: Create notification channel
tags:
- channels
/api/v2/notification_channels/{id}:
delete:
deprecated: false
description: This endpoint deletes a notification channel by ID
operationId: DeleteNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:delete
- tokenizer:
- notification-channel:delete
summary: Delete notification channel
tags:
- channels
get:
deprecated: false
description: This endpoint returns a notification channel by ID. A channel written
by the v1 API can carry a configuration this API does not model.
operationId: GetNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:read
- tokenizer:
- notification-channel:read
summary: Get notification channel by ID
tags:
- channels
put:
deprecated: false
description: '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.'
operationId: UpdateNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesUpdatableNotificationChannel'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:update
- tokenizer:
- notification-channel:update
summary: Update notification channel
tags:
- channels
/api/v2/notification_channels/test:
post:
deprecated: false
description: 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.
operationId: TestNotificationChannel
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesTestableNotificationChannel'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:create
- tokenizer:
- notification-channel:create
summary: Test notification channel
tags:
- channels
/api/v2/orgs/me:
get:
deprecated: false

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

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

@@ -507,6 +507,15 @@ export enum AlertmanagertypesChannelKindDTO {
jsmops = 'jsmops',
incidentio = 'incidentio',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum AlertmanagertypesChannelListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -1000,6 +1009,44 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
wont_fix_resolution?: string;
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesListableNotificationChannelDTO {
/**
* @type array
*/
channels: AlertmanagertypesListedNotificationChannelDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
@@ -2391,6 +2438,14 @@ export interface AlertmanagertypesReceiverDTO {
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AlertmanagertypesTestableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
export interface AuthtypesAttributeMappingDTO {
/**
* @type string
@@ -4079,6 +4134,53 @@ export interface Querybuildertypesv5LogAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
linear = 'linear',
}
export interface Querybuildertypesv5LinearBucketsSpecDTO {
/**
* @type number
* @format double
*/
maxValue: number;
/**
* @type integer
*/
numBuckets?: number;
}
export interface Querybuildertypesv5BucketOptionsLinearDTO {
/**
* @type string
* @enum linear
*/
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
spec: Querybuildertypesv5LinearBucketsSpecDTO;
}
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
log = 'log',
}
export interface Querybuildertypesv5LogBucketsSpecDTO {
/**
* @type integer,null
*/
scale?: number | null;
}
export interface Querybuildertypesv5BucketOptionsLogDTO {
/**
* @type string
* @enum log
*/
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
spec: Querybuildertypesv5LogBucketsSpecDTO;
}
export type Querybuildertypesv5BucketOptionsDTO =
| Querybuildertypesv5BucketOptionsLinearDTO
| Querybuildertypesv5BucketOptionsLogDTO;
export interface Querybuildertypesv5FilterDTO {
/**
* @type string
@@ -4272,6 +4374,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4399,6 +4502,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4474,6 +4578,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4914,6 +5019,57 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesListPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
'signoz/TextPanel' = 'signoz/TextPanel',
}
export interface DashboardtypesHeaderOptionsDTO {
/**
* @type boolean
*/
hide?: boolean;
}
export enum DashboardtypesTextModeDTO {
markdown = 'markdown',
}
export enum DashboardtypesTextAlignDTO {
left = 'left',
center = 'center',
right = 'right',
}
export enum DashboardtypesVerticalAlignDTO {
top = 'top',
center = 'center',
bottom = 'bottom',
}
export interface DashboardtypesTextPresentationDTO {
/**
* @type string,null
*/
background?: string | null;
textAlign?: DashboardtypesTextAlignDTO;
verticalAlign?: DashboardtypesVerticalAlignDTO;
}
export interface DashboardtypesTextPanelSpecDTO {
headerOptions?: DashboardtypesHeaderOptionsDTO;
mode?: DashboardtypesTextModeDTO;
presentation?: DashboardtypesTextPresentationDTO;
/**
* @type string
*/
text?: string;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
/**
* @enum signoz/TextPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
spec: DashboardtypesTextPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -4921,7 +5077,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -4929,6 +5086,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
@@ -4975,6 +5133,7 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type boolean
*/
@@ -5843,6 +6002,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
}
/**
* @nullable
@@ -8542,16 +8702,7 @@ export interface Querybuildertypesv5LabelDTO {
value?: Querybuildertypesv5LabelDTOValue;
}
export interface Querybuildertypesv5BucketDTO {
/**
* @type number
* @format double
*/
step?: number;
}
export interface Querybuildertypesv5TimeSeriesValueDTO {
bucket?: Querybuildertypesv5BucketDTO;
/**
* @type boolean
*/
@@ -9102,12 +9253,16 @@ export interface PromotetypesPromotePathDTO {
promote?: boolean;
}
export type Querybuildertypesv5AggregationBucketDTOMeta = {
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
*/
buckets?: number[];
/**
* @type string
*/
unit?: string;
};
}
export interface Querybuildertypesv5AggregationBucketDTO {
/**
@@ -9126,10 +9281,7 @@ export interface Querybuildertypesv5AggregationBucketDTO {
* @type array
*/
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
/**
* @type object
*/
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
meta?: Querybuildertypesv5AggregationMetaDTO;
/**
* @type array
*/
@@ -9144,6 +9296,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
}
export enum Querybuildertypesv5BucketsKindDTO {
linear = 'linear',
log = 'log',
}
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
/**
* @type string
@@ -9610,6 +9766,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9658,6 +9818,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9753,6 +9917,7 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',
@@ -13162,6 +13327,44 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type ListNotificationChannelsParams = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
kind?: AlertmanagertypesChannelKindDTO;
/**
* @description undefined
*/
sort?: AlertmanagertypesChannelListSortDTO;
/**
* @description undefined
*/
order?: AlertmanagertypesChannelListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListNotificationChannels200 = {
data: AlertmanagertypesListableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
@@ -13170,6 +13373,31 @@ export type CreateNotificationChannel201 = {
status: string;
};
export type DeleteNotificationChannelPathParameters = {
id: string;
};
export type GetNotificationChannelPathParameters = {
id: string;
};
export type GetNotificationChannel200 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type UpdateNotificationChannelPathParameters = {
id: string;
};
export type UpdateNotificationChannel200 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

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

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

View File

@@ -0,0 +1,118 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import {
PanelTheme,
TextBackgroundKind,
TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import BackgroundSwatches from '../BackgroundSwatches';
function renderRow(
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<BackgroundSwatches
testId="background"
label="Panel background"
theme={PanelTheme.Dark}
value={TextBackgroundKind.Default}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
return onChange;
}
describe('BackgroundSwatches', () => {
it('offers transparent, the default surface and the eight presets in order', () => {
renderRow();
expect(
screen
.getAllByRole('radio')
.map((swatch) => swatch.getAttribute('aria-label')),
).toStrictEqual([
'Transparent',
'Default panel',
'Robin',
'Purple',
'Sakura',
'Cherry',
'Amber',
'Forest',
'Sienna',
'Slate',
]);
});
it('is one labelled group', () => {
renderRow();
expect(
screen.getByRole('radiogroup', { name: 'Panel background' }),
).toBeInTheDocument();
});
it.each([
['background-none', 'Transparent — no card, border or title bar'],
['background-default', 'Default panel colour'],
['background-sakura', 'Sakura'],
])('explains %s on hover', async (swatchId, copy) => {
renderRow();
fireEvent.focus(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
it('paints each preset in the given theme', () => {
renderRow({ theme: PanelTheme.Light });
expect(screen.getByTestId('background-amber')).toHaveStyle({
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
});
});
it('marks only the selected swatch, and checks it', () => {
renderRow({ value: TextBackgroundPreset.Forest });
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
expect(
screen.getByRole('radio', { name: 'Default panel' }),
).not.toBeChecked();
expect(
screen.getByTestId('background-forest').querySelector('svg'),
).toBeInTheDocument();
expect(
screen.getByTestId('background-default').querySelector('svg'),
).not.toBeInTheDocument();
});
it('reports the swatch that was clicked', () => {
const onChange = renderRow();
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
expect(onChange).toHaveBeenCalledWith('sienna');
});
// jsdom does not implement radio arrow navigation, so the shared name — what
// makes them one group — is what there is to assert.
it('groups every swatch under one radio name', () => {
renderRow();
const names = new Set(
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
);
expect(names).toStrictEqual(new Set(['background']));
});
});

View File

@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react';
import CustomBackgroundRow from '../CustomBackgroundRow';
function renderRow(value?: string): jest.Mock {
const onChange = jest.fn();
render(
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
);
return onChange;
}
describe('CustomBackgroundRow', () => {
it('stands in for the hex while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
});
it('shows the stored hex once one is set', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
});
it('checks the chip only while the custom colour is the selection', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
});
it('leaves the chip unchecked while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
});
describe('the picker', () => {
it('opens on the row', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
});
it('reports the contrast the colour achieves', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
// The derived ink is pure white, not purple's paired ink.
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'Contrast 12.4:1',
);
});
it('warns when no ink clears the floor, without disabling anything', () => {
renderRow('#808080');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'below 4.5:1',
);
expect(screen.getByTestId('custom')).toBeEnabled();
});
it('says nothing about the floor when the colour clears it', () => {
renderRow('#111111');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
});
});
});

View File

@@ -5,7 +5,7 @@ import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from '../../../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from '../../../../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from '../../../../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from '../../../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(

View File

@@ -1,7 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from '../utils/legendSeries';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -23,6 +23,8 @@ import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppea
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -117,6 +119,23 @@ export const SECTION_REGISTRY: {
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
},
[SectionKind.TextLayout]: {
Component: TextLayoutSection,
get: (spec): SectionSpecMap[SectionKind.TextLayout] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
update: (spec, presentation): PanelSpec =>
updatePluginSlice(spec, 'presentation', presentation),
},
[SectionKind.PanelHeader]: {
Component: PanelHeaderSection,
get: (spec): SectionSpecMap[SectionKind.PanelHeader] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.PanelHeader]>(
spec,
'headerOptions',
),
update: (spec, headerOptions): PanelSpec =>
updatePluginSlice(spec, 'headerOptions', headerOptions),
},
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.

View File

@@ -0,0 +1,24 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
function PanelHeaderSection({
value,
onChange,
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
return (
<ConfigSwitch
testId="panel-header-hide"
title="Hide header"
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
value={value?.hide === true}
onChange={(hide): void => onChange({ ...value, hide })}
/>
);
}
export default PanelHeaderSection;

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import PanelHeaderSection from '../PanelHeaderSection';
describe('PanelHeaderSection', () => {
it('toggles hide on', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={undefined} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: true });
});
it('toggles hide back off', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={{ hide: true }} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: false });
});
it('shows the header by default when the slice is empty', () => {
render(<PanelHeaderSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-header-hide')).not.toBeChecked();
});
});

View File

@@ -0,0 +1,11 @@
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,99 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
resolveTextBackground,
selectionFromResolved,
storedFromSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/resolveTextBackground';
import type { TextBackgroundSelection } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import {
PanelTheme,
TextBackgroundKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import BackgroundSwatches from '../../controls/BackgroundSwatches/BackgroundSwatches';
import CustomBackgroundRow from '../../controls/BackgroundSwatches/CustomBackgroundRow';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './TextLayoutSection.module.scss';
const HORIZONTAL_OPTIONS = [
{ value: DashboardtypesTextAlignDTO.left, label: 'Left' },
{ value: DashboardtypesTextAlignDTO.center, label: 'Center' },
{ value: DashboardtypesTextAlignDTO.right, label: 'Right' },
];
const VERTICAL_OPTIONS = [
{ value: DashboardtypesVerticalAlignDTO.top, label: 'Top' },
{ value: DashboardtypesVerticalAlignDTO.center, label: 'Middle' },
{ value: DashboardtypesVerticalAlignDTO.bottom, label: 'Bottom' },
];
/**
* Edits the Text panel's `presentation` slice: body alignment and the card
* background (TDD D7 — scoped to the text spec, not the panel envelope).
*/
function TextLayoutSection({
value,
onChange,
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
const theme = useIsDarkMode() ? PanelTheme.Dark : PanelTheme.Light;
const background = resolveTextBackground(value?.background, theme);
return (
<div className={styles.section}>
<div className={styles.field}>
<Typography.Text>Horizontal alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-horizontal-align"
items={HORIZONTAL_OPTIONS}
value={value?.textAlign ?? DashboardtypesTextAlignDTO.left}
onChange={(textAlign): void => onChange({ ...value, textAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Vertical alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-vertical-align"
items={VERTICAL_OPTIONS}
value={value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top}
onChange={(verticalAlign): void => onChange({ ...value, verticalAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Background</Typography.Text>
<BackgroundSwatches
testId="text-layout-background"
label="Panel background"
theme={theme}
value={selectionFromResolved(background)}
onChange={(selection: TextBackgroundSelection): void =>
onChange({
...value,
background: storedFromSelection(selection, theme),
})
}
/>
<CustomBackgroundRow
testId="text-layout-background-custom"
value={
background.kind === TextBackgroundKind.Custom
? background.surface
: undefined
}
onChange={(hex): void => onChange({ ...value, background: hex })}
/>
</div>
</div>
);
}
export default TextLayoutSection;

View File

@@ -0,0 +1,144 @@
import type { ReactElement } from 'react';
import {
fireEvent,
render as rtlRender,
type RenderResult,
screen,
} from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
TEXT_BACKGROUND_PAIRS,
TRANSPARENT_BACKGROUND,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import TextLayoutSection from '../TextLayoutSection';
const value = {
textAlign: DashboardtypesTextAlignDTO.left,
verticalAlign: DashboardtypesVerticalAlignDTO.top,
};
// The swatch tooltips need a provider; AppLayout supplies one at runtime.
function render(ui: ReactElement): RenderResult {
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
}
// The theme context defaults to dark, so the swatches paint the dark pairs.
describe('TextLayoutSection', () => {
it('changes horizontal alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Center'));
expect(onChange).toHaveBeenCalledWith({
...value,
textAlign: DashboardtypesTextAlignDTO.center,
});
});
it('changes vertical alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Bottom'));
expect(onChange).toHaveBeenCalledWith({
...value,
verticalAlign: DashboardtypesVerticalAlignDTO.bottom,
});
});
it('stores the surface of the theme a preset was picked in', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByRole('radio', { name: 'Amber' }));
expect(onChange).toHaveBeenCalledWith({
...value,
background: TEXT_BACKGROUND_PAIRS.amber.dark.surface,
});
});
it('stores a zero-alpha colour for transparent', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByRole('radio', { name: 'Transparent' }));
expect(onChange).toHaveBeenCalledWith({
...value,
background: TRANSPARENT_BACKGROUND,
});
});
it('unsets the background for the default panel surface', () => {
const onChange = jest.fn();
render(
<TextLayoutSection
value={{ ...value, background: TRANSPARENT_BACKGROUND }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: 'Default panel' }));
expect(onChange).toHaveBeenCalledWith({ ...value, background: undefined });
});
it('lights up the swatch the stored surface belongs to', () => {
render(
<TextLayoutSection
value={{ ...value, background: TEXT_BACKGROUND_PAIRS.sakura.light.surface }}
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('radio', { name: 'Sakura' })).toBeChecked();
});
it('stores a custom colour straight from the picker', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByTestId('text-layout-background-custom'));
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '3A2A64' },
});
expect(onChange).toHaveBeenCalledWith({
...value,
background: '#3a2a64',
});
});
it('shows a stored custom colour on the custom row alone', () => {
render(
<TextLayoutSection
value={{ ...value, background: '#3A2A64' }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('text-layout-background-custom')).toHaveTextContent(
'#3A2A64',
);
expect(
screen
.getAllByRole<HTMLInputElement>('radio')
.filter((swatch) => swatch.checked),
).toHaveLength(0);
});
it('selects the default surface when nothing is stored', () => {
render(<TextLayoutSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByRole('radio', { name: 'Default panel' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Transparent' })).not.toBeChecked();
});
});

View File

@@ -8,9 +8,14 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
// Open the antd Select by clicking its selector, then pick the option by label.

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import type { ReactNode } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import layoutStorage from '../layoutStorage';
import styles from '../PanelEditor.module.scss';
/** A resizable pane's bounds, in the percentage strings `ResizablePanel` takes. */
interface PaneSize {
minSize: string;
maxSize: string;
defaultSize: string;
}
/** How the left column divides between the preview and the editor pane. */
export interface PaneSplit {
preview: PaneSize;
editor: PaneSize;
}
/**
* Vertical split per authoring mode. The query builder is a compact form, so the
* preview keeps the room; a static kind's editor pane is the surface being worked
* in, so it gets more and can grow further.
*/
export const PANE_SPLIT = {
query: {
preview: { minSize: '55%', maxSize: '65%', defaultSize: '60%' },
editor: { minSize: '35%', maxSize: '45%', defaultSize: '40%' },
},
static: {
preview: { minSize: '40%', maxSize: '65%', defaultSize: '55%' },
editor: { minSize: '35%', maxSize: '60%', defaultSize: '45%' },
},
} as const satisfies Record<string, PaneSplit>;
interface PanelEditorLayoutProps {
/** Save/close chrome — its affordances differ per authoring mode. */
header: ReactNode;
/** Upper-left: what the panel will look like once saved. */
preview: ReactNode;
/** Lower-left: the kind's `EditorPane` — the query builder, or a static kind's own. */
editor: ReactNode;
/** Right column: the kind's config sections. */
config: ReactNode;
split: PaneSplit;
}
/**
* The panel editor's frame: header, the resizable three-pane arrangement, and the
* persistence of what the user drags. Owned once so both authoring modes cannot
* drift apart on pane bounds or share a layout id by accident — they differ only in
* `split` and in what fills the slots.
*/
function PanelEditorLayout({
header,
preview,
editor,
config,
split,
}: PanelEditorLayoutProps): JSX.Element {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
return (
<div className={styles.page} data-testid="panel-editor-v2">
{header}
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel {...split.preview}>{preview}</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel {...split.editor}>{editor}</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
{config}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorLayout;

View File

@@ -21,20 +21,15 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
@@ -55,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelKind,
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
@@ -65,10 +60,10 @@ function PanelEditorQueryBuilder({
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = toPanelType(panelDefinition.kind);
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelKind === 'signoz/ListPanel';
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -99,12 +94,12 @@ function PanelEditorQueryBuilder({
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
[panelDefinition.queryBuilderFields, signal],
);
const items = useMemo(() => {
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const { supportedQueryTypes } = panelDefinition;
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -151,7 +146,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
return (
<div

View File

@@ -0,0 +1,28 @@
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
/**
* The default query-kind editor pane: the query-builder tabs with no extras. A
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
*/
function QueryBuilderEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
/>
);
}
export default QueryBuilderEditorPane;

View File

@@ -4,6 +4,9 @@ import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
@@ -48,7 +51,7 @@ function renderBuilder(
): void {
render(
<PanelEditorQueryBuilder
panelKind={panelKind as never}
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}

View File

@@ -9,6 +9,13 @@
border-bottom: 1px solid var(--l1-border);
}
// A static pane never scrolls — the panel card clips, and the renderer scrolls its
// own body when the content outgrows it, as on the grid.
.previewStatic {
box-sizing: border-box;
overflow: hidden;
}
.header {
width: 100%;
box-sizing: border-box;
@@ -56,6 +63,14 @@
overflow: visible;
}
// A static kind's card takes its colours from the background the panel declares,
// falling back to the same tokens the query surface uses.
.surfaceStatic {
border-color: var(--text-panel-border, var(--l2-border));
background: var(--text-panel-surface, var(--l2-background));
color: var(--text-panel-ink, inherit);
}
.state {
flex: 1;
display: flex;

View File

@@ -5,10 +5,16 @@ import { PanelMode } from 'lib/visualization/panels/types';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type {
RenderableQueryPanelDefinition,
RenderableStaticPanelDefinition,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
import type {
PanelPagination,
PanelQueryData,
@@ -17,11 +23,17 @@ import type {
import PlotTag from './PlotTag';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
interface PreviewPaneBaseProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
}
interface QueryPreviewPaneProps extends PreviewPaneBaseProps {
mode: 'query';
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
panelDefinition: RenderableQueryPanelDefinition;
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
@@ -34,8 +46,6 @@ interface PreviewPaneProps {
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
hideHeader?: boolean;
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
@@ -48,41 +58,43 @@ interface PreviewPaneProps {
enableDrillDown?: boolean;
}
interface StaticPreviewPaneProps extends PreviewPaneBaseProps {
mode: 'static';
/** The kind's definition, narrowed to the static arm — no query, no Run step. */
panelDefinition: RenderableStaticPanelDefinition;
/** Saves an edit made from the rendered body into the draft; absent = read-only. */
onChangeText?: (text: string) => void;
}
type PreviewPaneProps = QueryPreviewPaneProps | StaticPreviewPaneProps;
/**
* Live preview for the panel editor: renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode` differs), so the preview is the
* production render path. The query result is owned by the editor root.
* Live preview for the panel editor and the View modal: the draft rendered through
* the same body the dashboard grid uses (only `panelMode` differs), so the preview
* is the production render path. A query draft's result is owned by the editor
* root; a static draft re-renders straight from the spec on every edit.
*/
function PreviewPane({
panelId,
panel,
panelDefinition,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
pagination,
panelMode = PanelMode.DASHBOARD_EDIT,
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const queryType = getPanelQueryType(panel);
function PreviewPane(props: PreviewPaneProps): JSX.Element {
const { panelId, panel, panelMode = PanelMode.DASHBOARD_EDIT } = props;
const query = props.mode === 'query' ? props : null;
const staticDraft = props.mode === 'static' ? props : null;
const background = useTextBackground(panel.spec);
// Search term is ephemeral preview state, threaded to header + renderer but
// not persisted to the draft spec. Only kinds that declare it render the box.
const searchable = !!panelDefinition.actions.search;
const searchable = !!query?.panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
return (
<div className={styles.preview}>
{!hideHeader && (
<div
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
>
{query && !query.hideHeader && (
<div className={styles.header}>
<PlotTag queryType={queryType} className={styles.queryType} />
<PlotTag
queryType={getPanelQueryType(panel)}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
@@ -91,39 +103,67 @@ function PreviewPane({
<div className={styles.container}>
<div
className={cx(styles.surface, {
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
[styles.surfaceStacked]:
!!query && panelMode === PanelMode.STANDALONE_VIEW,
[styles.surfaceStatic]: !!staticDraft,
})}
style={background.style}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
onClick={onClick}
enableDrillDown={enableDrillDown}
/>
{query ? (
<>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={query.data}
isFetching={query.isFetching}
error={query.error}
warning={query.data.response?.data?.warning}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
hideActions
/>
<PanelBody
Renderer={query.panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={query.data}
isFetching={query.isFetching}
isPreviousData={query.isPreviousData}
error={query.error}
refetch={query.refetch}
onDragSelect={query.onDragSelect}
panelMode={panelMode}
dashboardPreference={query.dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={query.pagination}
onCloseStandaloneView={query.onCloseStandaloneView}
onClick={query.onClick}
enableDrillDown={query.enableDrillDown}
/>
</>
) : (
staticDraft && (
<>
{!isPanelHeaderHidden(panel.spec) && (
<PanelHeader
mode="static"
panelId={panelId}
panel={panel}
hideActions
/>
)}
<StaticPanelBody
Renderer={staticDraft.panelDefinition.Renderer}
panel={panel}
panelId={panelId}
panelMode={panelMode}
onChangeText={staticDraft.onChangeText}
/>
</>
)
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,333 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface QueryEditorBodyProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** The draft kind's definition, narrowed by the shell's fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind editor body: a resizable split with the live preview + the
* kind's editor pane on the left and the config pane on the right. Draft and
* kind-switch state live in the shell; this body owns the query session and the
* save round-trip.
*/
function QueryEditorBody({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
draftApi,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const panelKind = draft.spec.plugin.kind;
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
const { EditorPane } = panelDefinition;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: toPanelType(panelKind),
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<PanelEditorLayout
split={PANE_SPLIT.query}
header={
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
}
preview={
<PreviewPane
mode="query"
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
}
editor={
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<EditorPane
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}
onChangeSpec={setSpec}
/>
</ConfigProvider>
}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
}
/>
);
}
export default QueryEditorBody;

View File

@@ -0,0 +1,134 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import PreviewPane from './PreviewPane/PreviewPane';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { withPanelText } from '../Panels/utils/withPanelText';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* Editor body for a kind that renders from its own plugin spec: the kind's
* editor pane under a live preview of the draft, the config pane on the right.
* No query session, no builder seeding, no staged-run — the preview re-renders
* from the draft spec on every edit.
*/
function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const { draft, spec, setSpec, isSpecDirty } = draftApi;
const { EditorPane } = panelDefinition;
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// `queries: []` is the only shape the API accepts for a static kind.
const savedPanelId = await save({ ...draft.spec, queries: [] });
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
const onChangeText = useCallback(
(text: string): void => setSpec(withPanelText(spec, text)),
[spec, setSpec],
);
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
return (
<PanelEditorLayout
split={PANE_SPLIT.static}
header={
<Header
isDirty={isSpecDirty}
isSaving={isSaving}
showSwitchToView={false}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onClose={onCloseEditor}
/>
}
preview={
<PreviewPane
mode="static"
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
panelMode={PanelMode.DASHBOARD_EDIT}
onChangeText={isEditable ? onChangeText : undefined}
/>
}
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>
}
/>
);
}
export default StaticEditorBody;

View File

@@ -3,9 +3,30 @@ import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
// The editor reads its edit context from the loaded dashboard subtree, which
// these composition cases don't stand up; the derivation has its own suite.
const mockEditContext = {
isEditable: true,
editChecks: [],
areOtherPermissionsLoading: false,
deleteChecks: [],
isLocked: false,
canEditDashboard: true,
canDeleteDashboard: true,
editDisabledTooltip: '',
deleteDisabledTooltip: '',
};
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
}),
);
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
/**
@@ -172,8 +193,6 @@ function makePanel(
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
isEditable: true,
editDisabledReason: '',
onClose: jest.fn(),
onSaved: jest.fn(),
};
@@ -232,7 +251,12 @@ describe('PanelEditorContainer composition', () => {
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
expect.objectContaining({
panelDefinition: expect.objectContaining({
kind: 'signoz/TimeSeriesPanel',
mode: 'query',
}),
}),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -256,7 +280,7 @@ describe('PanelEditorContainer composition', () => {
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(

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