Compare commits

...

18 Commits

Author SHA1 Message Date
Abhi Kumar
405dbbea2e feat(dashboards): add the Text panel's Markdown renderer
MarkdownContent renders an authored body. It is panel-local rather than shared
because the shared MarkdownRenderer enables rehype-raw, which is safe only for
the trusted content it was built for; this one never gets it, so raw HTML in a
user-authored body renders as text with no dangerouslySetInnerHTML on the path.
A rejected `javascript:` href drops the anchor rather than rendering
react-markdown's inert stand-in.

Its stylesheet reverts the subtree to user-agent styling so no global rule
reaches the rendered body. 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.

Fenced blocks highlight with Prism at `useInlineStyles: false`, keeping the
token palette on design tokens, and load their language per fence. Each block
carries the shared periscope copy button, revealed on hover or focus, copying
the source exactly as fenced.

jest.config gains remark-gfm and its ESM-only dependencies; nothing had
exercised the plugin under jest before.
2026-09-05 18:15:39 +05:30
Abhi Kumar
b582f96a2d feat(dashboards): add a Markdown editor for panel authoring
The authoring surface that replaces the query-builder pane for query-less panel
kinds: a formatting toolbar over a CodeMirror document, a searchable
insert-variable menu, and a caret/character-count status bar.

Toolbar commands are pure snapshot-to-snapshot transforms with no CodeMirror
coupling, so a new action is one registry entry plus an icon. Markdown colouring
is a decoration pass rather than a grammar, which keeps it on the CodeMirror
packages already bundled instead of pulling in a language mode 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, so the seed runs from an `isEditorReady`-gated effect instead.

Nothing imports this yet; the Text panel kind wires it up in a follow-up.
2026-09-05 18:15:39 +05:30
Abhi Kumar
61f668ca30 refactor(dashboards): let getPanelBuilderQuery answer for a query-less kind
Both callers that stage a panel's query into the URL asked the capabilities guard
whether the kind was query-less before calling getPanelBuilderQuery — the same
question, phrased the same way, in two hooks that otherwise share nothing.

The function itself knows: it already reads the kind's definition for a default
signal, so it returns null when there is no query arm and callers skip the
compositeQuery param on a falsy result.

Assisted-by: Claude Opus 5
2026-09-05 18:05:32 +05:30
Abhi Kumar
48b12c6247 refactor(dashboards): own the panel editor's frame once
Forking the editor on authoring mode copied the whole frame into both arms: the
header, both ResizablePanelGroups, the handles and the layout persistence. The
two differ only in the pane sizes and in what fills the preview and editor
slots — everything else was duplicated, including the layout ids, so the arms
shared persisted state while each owned its own copy of the sizes. Changing one
would have silently diverged them.

PanelEditorLayout owns the frame and takes the slots. The per-mode sizes sit
together in PANE_SPLIT, where they can be compared, instead of inline in two
files: the query builder is a compact form so the preview keeps the room, while
a static kind's editor pane is the surface being worked in.

Assisted-by: Claude Opus 5
2026-09-05 18:05:32 +05:30
Abhi Kumar
a97e9838ad refactor(dashboards): fork the View modal on authoring mode
Phase 3b of the Text panel plan — the View modal gets the same shell the
editor got: ViewPanelModalContent now owns the draft and the kind-switch cache
(so a switch across authoring modes survives the branch swap) plus the
mount-only URL/handoff seeding, guarded so a composite query can only seed a
kind that takes one. The query body is today's content unchanged
(QueryViewModalBody); the static body renders the panel live over the kind's
editor pane, with a kind switcher and an "Edit panel" handoff that carries
in-modal edits — no time window, no query session, no drilldown.

useViewPanelMode drops its seeding and switch concerns and takes the hoisted
draft, which is what keeps its query machinery unmounted for static kinds.
Opening a static panel writes only the expanded-panel id: with no query to
stage or persist, nothing reaches the URL or the shared builder.

Assisted-by: Claude Fable 5
(cherry picked from commit 7af199fcb2)
2026-09-05 17:35:06 +05:30
Abhi Kumar
368b2a0648 refactor(dashboards): fork the panel editor on authoring mode
Phase 3a of the Text panel plan. PanelEditorContainer becomes a shell owning
exactly the state that must survive a switch between authoring modes — the
draft and the kind-switch cache — and forks on the draft kind's `mode`:

- QueryEditorBody is today's editor body unchanged, now fed the hoisted draft
  and the narrowed definition. The lower pane comes from the definition's
  EditorPane: a shared QueryBuilderEditorPane for six kinds, and a List wrapper
  that absorbs the columns-editor footer both the editor and the View modal
  previously special-cased inline.
- StaticEditorBody is the query-less body: the kind's editor pane under a live
  preview of the draft (the same StaticPanelBody the grid renders), saving with
  `queries: []` — the only shape the API accepts. No builder seeding, no staged
  run, no compositeQuery URL writes; opening the editor on a static kind stamps
  no default query into the URL either.
- usePanelTypeSwitch handles static targets: first visit gets a fresh spec with
  queries emptied and the query builder left untouched; the per-kind cache makes
  the round trip restore both sides.

PanelEditorQueryBuilder now reads the narrowed definition it is handed instead
of looking capabilities up by kind, which also breaks the would-be import cycle
definition → pane → capabilities → registry → definition.

Still unreachable: no static kind is registered until the registration phase.

Assisted-by: Claude Fable 5
(cherry picked from commit f065874402)
2026-09-05 17:35:06 +05:30
Abhi Kumar
46e6132297 feat(dashboards): render static panel kinds on the grid and public view
Phase 2 of the Text panel plan. StaticPanel (grid) and StaticPublicPanel mount
a static kind's renderer behind the shared panel chrome — no fetch, no status
indicators, no time preference, no drilldown, because none of that exists
without a query. StaticPanelBody is shared by both hosts (and later the editor
preview) and resolves `dashboardId` from the edit-context store, so previews of
unsaved panels read variables the same way the grid does.

Still unreachable: no static kind is registered, so both arms are exercised by
fork tests with the registry mocked — asserting the query hook never mounts.

(cherry picked from commit 1f1fe69f30)
2026-09-05 17:35:06 +05:30
Abhi Kumar
f54a33c3ee refactor(dashboards): split PanelDefinition into query and static arms
Phase 1 of the Text panel plan (frontend/docs/text-panel-implementation-plan.md).

A definition is now one of two shapes discriminated by a root `mode`: query
kinds declare their whole query surface (renderer, signals, query types,
builder fields, request capabilities); static kinds — none exist yet — declare
a renderer that takes no query data and an editor pane that replaces the query
builder. No dummy capabilities, no empty declarations 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, PanelBody takes the query arm's Renderer directly, and readers that
can't take the definition as a prop yet assert the arm via
requireQueryPanelDefinition. Leaf query hooks keep non-null contracts.

No behavior change: generated types are untouched, so the kind universe is
still the seven query kinds and the static arm of every fork is unreachable.

(cherry picked from commit 526ff46441)
2026-09-05 17:35:05 +05:30
Abhi Kumar
c170707757 refactor(dashboards): declare the legend colors control as its resolver
useLegendSeries switched on the panel kind to pick how a panel's output becomes
legend entries — pie slices or flat series — while the kinds that expose the
colors control were already declaring it as Legend.controls.colors. Two places
described the same fact, so a new chart kind could declare the control and
silently get an empty color picker.

The colors control now carries the resolver instead of a boolean: declaring the
resolver is declaring the control, so the two cannot disagree. The hook becomes a
lookup and a call with no switch, and LegendSection's truthiness check is
unchanged.

legendSeries.ts moves from PanelEditor/utils to Panels/utils — it only ever
imported from Panels and queryV5, and kinds cannot import up into the editor. Both
resolvers take one args object so pie needs no placeholder parameter.

Assisted-by: Claude Opus 5
2026-09-05 17:33:10 +05:30
Abhi Kumar
f6c34795a5 refactor(dashboards): read alert units and thresholds from the declarations
Both alert helpers switched on the panel kind: readPanelUnit listed the four
kinds that carry a unit, readPanelThresholds listed the shapes each kind's
thresholds take. Every new kind has to be added to both, and a missed one loses
its alert prefill silently — there is no failure, just a missing unit.

Both facts are already declared. A kind's Formatting section says whether it
exposes a unit, and its Thresholds section says which variant it edits, so
getSectionControls answers both by reading the kind's own sections.ts. It also
replaces the private copy of the same lookup in newPanelSeed.

A table variant contributes no prefill: its thresholds are per column, which has
no meaning for a panel-wide alert condition.

Assisted-by: Claude Opus 5
2026-09-05 17:32:59 +05:30
Abhi Kumar
37ef1cd1db refactor(dashboards): offer panel kinds from the registry
The new-panel picker and the editor's kind switcher both read a hand-maintained
PANEL_TYPES array. A kind absent from it renders fine on a saved dashboard but
can never be created or switched to, and nothing catches the omission.

Each kind now declares its picker icon next to the displayName it already
declares, and both surfaces render Object.values(PANELS). Registry declaration
order is display order, so registry.ts is reordered to keep today's tile order.
The parallel array and its PanelType interface are deleted.

Assisted-by: Claude Opus 5
2026-09-05 17:32:40 +05:30
Naman Verma
e0da06f76d chore: add type for strings that should be unset or non empty (#12774)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (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

If an API request body has a field that cannot be empty, but backend can
fill in a default value for it, then backend should fill that default
value only when the field is omitted in the request. If the user has
explicitly sent `""`, then backend should reject it so that there is no
request-response drift.

Such fields can be typed as the new `UnsetOrNonEmptyString` which has
custom unmarshalling logic. If the field is set in the request json,
then the custom unmarshal logic is called and explicit `""` is rejected.
If the field is not set, then the function is not called, and further
backend logic is free to fill in the default value.

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

Came out of notification channels V2 API work.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-04 15:31:19 +00:00
Naman Verma
85f9924b4b fix: reject trailing / in jira urls instead of trimming (#12772)
<!--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

This is so that a round trip drift doesn't happen. This was caught for
incident io in its PR but we missed it out here.

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

Found as a part of round trip testing in v2 notification channels create
API

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-04 14:43:30 +00:00
Ashwin Bhatkal
c015622258 fix(quick-filters): keep the filter expression in sync with the filter items (#12755)
#### Description

Unchecking a value in a Quick Filter V2 checkbox filter could not be
undone: the first click excluded the value (`not in`), the second
flipped it to `in` instead of clearing it, and the third appeared to do
nothing.

Quick filters dispatch through the URL, and the composite-query parser
merges `filters.items` into `filter.expression` on the way back in. That
merge only adds and rewrites clauses — it never drops one — so a clause
left behind in the expression resurrects a filter the user just removed.
This is why the bug shows up only for `filter.expression` and not for
`filter.items`.

Two causes, both in `applyCheckboxToggle`:

- Several branches removed the key from `filters.items` without removing
it from `filter.expression`. Rather than patching each removal site, the
expression is now re-derived from the updated items once before
returning. That also covers operator swaps (`not in` → `in`), which the
merge cannot rewrite in place because it keys on key + operator. Four
ad-hoc strips became redundant and were dropped.
- Under a `NOT IN` clause, the "user clicked an unchecked value, so
select it" branch fired for every unchecked value. But under `NOT IN`
the unchecked values are exactly the excluded ones, so that branch
swallowed the "re-include this value" case and the removal branch below
it was unreachable. It is now gated on the value not already being
excluded; a genuinely unselected value in **All values** still becomes
`in`.

Toggling a value is now a two-state cycle: no clause ⇄ `not in
['value']`.

The second commit narrows what that re-derivation is allowed to touch,
fixing two adjacent defects of the same kind:

- It stripped **every** clause for the attribute key, including
predicates the checkbox does not own (`CONTAINS`, `EXISTS`, a range).
Clearing a filter deleted such a clause outright — the header's Clear
button stays active even when the checkboxes are disabled by a second
clause on the key — and a plain toggle rewrote it, losing the spelling
the user typed. `removeKeysFromExpression` takes an optional operator
restriction, and the checkbox passes the four operators it actually
emits.
- It matched keys literally while the items side matches by base name
via `isKeyMatch`, so a context-prefixed clause such as
`resource.service.name` was left in the expression and resurrected the
filter. Both sides now agree on which spellings are the same filter.
- `clearFilterFromQuery` stripped the expression at every query index
while filtering items only at the active one, churning a clause in other
queries that the round trip put straight back. It now leaves non-active
queries alone.

#### Issues closed by this PR

Closes https://github.com/SigNoz/platform-pod/issues/3054

#### Additional Information

The 127 pre-existing Quick Filters tests pass both before and after this
change — they assert UI state and the dispatched filter items, never the
resulting expression, which is the gap that let this through.

`checkboxFilterQuery.test.ts` replaces that gap with a table of 40 cases
that assert the structured items and the shipped expression
**together**, each one driven through the real URL round-trip. Against
the code before this PR the same table fails 9 cases. It covers the
incident's own click sequence — re-checking a value that is actually in
the exclusion list — which nothing previously exercised.

One unrelated pre-existing failure in this area, for anyone running the
suite: `QuerySearch.test.tsx › fetches key suggestions on mount for
LOGS` fails on `main` when that spec runs on its own.
2026-09-04 12:22:00 +00:00
Vikrant Gupta
0f36cb9334 feat(subscription): add subscription endpoints with resource authz (#12767)
#### Description

- Adds a `subscription` domain: `POST`, `PUT`, and `GET
/api/v1/subscriptions`, wired with `CheckResources` + `ResourceDef`s on
the `subscription` metaresource (`create`, `list` + `update`, `read`).
Community gets a noop implementation; enterprise talks to Zeus.
- Migration `125_add_subscription_tuples` backfills the admin
subscription tuples for existing organizations.
- The legacy `/api/v1/checkout`, `/api/v1/billing`, and `/api/v1/portal`
routes are untouched; they are deleted once the frontend has moved.

#### Additional Information

Part of SigNoz/platform-pod#3091.
2026-09-04 10:15:17 +00:00
Abhi kumar
e1ee386016 refactor(dashboards-v2): declare per-kind query capabilities instead of inferring them from panel types (#12559)
#### Description

V2 panels answered "how does this panel's query behave?" by comparing
against the legacy `PANEL_TYPES` enum. Each kind now declares it, so
adding a kind means stating its behaviour once instead of finding every
switch that should have mentioned it.

- **Kinds declare their query behaviour** — request type, table
formatting, step-interval and order treatment, paging, list-view
authoring, trace operator. `buildQueryRangeRequest` takes that block, so
the `PANEL_TYPES.BAR` / `.LIST` / `.TABLE` branches are gone. An
exhaustive `Record<PanelKind, …>` test means a new kind can't ship
without declaring its request shape.
- **The capabilities are passed in, not looked up.** The panel registry
carries every renderer with it, so importing it into the data path drags
the app's API client into anything that touches the request builder. The
call sites already resolve the definition.
- **The chart layer no longer infers a time axis from a panel type.**
`UPlotAxisBuilder` decided X-axis date formatting from a hardcoded
`[TIME_SERIES, BAR]` list, so a chart that plots time but isn't one of
those two silently lost its formatted ticks — no type error, no failing
test. Callers now declare `isTimeAxis`.
- **`getPanelDefinition` always resolves.** It was typed to return a
definition for any `PanelKind`, but the registry only holds registered
kinds, and a spec from a newer SigNoz names one this build has never
heard of. Callers coped by truthiness-checking a value the type said
couldn't be falsy — a lint autofix had already deleted one such guard in
`PublicPanel`. Unknown kinds now resolve to `UNSUPPORTED_PANEL`, which
declares nothing and renders as unsupported; `isPanelKindSupported` is
the separate question the lazy fetch and editor session actually needed.
- **Analytics gained `panelKind`** on all seven panel events, alongside
the existing `panelType` so current reports keep resolving. `panelType`
can't distinguish two kinds that map onto it.
- **Removed `ViewPanelQueryBuilder`** — no importers; the View modal
renders `PanelEditorQueryBuilder`. It referenced a stylesheet class that
no longer exists.

Behaviour is unchanged for every registered kind. The one visible
difference: a panel whose kind this build can't render now says so,
instead of rendering a header above an empty body.

#### Issues Closed
Closes https://github.com/SigNoz/pulse-pod/issues/279

#### Additional Information

- **Read it commit by commit** — each is one theme (declare / request
path / axis / builder mode / analytics / registry), and the diff is
mostly deletions once the declarations are in place.
- The legacy enum still appears in ~28 V2 files, all of it *translation
at a boundary* rather than a decision: the V1 `Query` pivot
(`mapCompositeQueryFromQuery` writes `panelType` into
`ICompositeMetricQuery`), URL params (`graphType` / `panelTypes` are a
serialised contract), the shared `QueryBuilderV2` provider (where
`panelType` is provider state read by its subcomponents), and analytics.
A follow-up will quarantine those into a single boundary module with a
lint rule keeping them there.
- The last commit deletes `resolveQueryCapabilities`, added earlier in
this branch: it existed only to absorb a missing definition, which the
registry no longer produces.
2026-09-04 09:54:52 +00:00
Abhi kumar
717d37a945 chore(dashboard): retire the V1 variable runtime (#12710)
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
The V1 variable engine had no writers left: nothing wrote selectedValue,
so getDashboardVariables produced undefined values, variableFetchStore
was never updated, and the dependency graph and derived store fields
only fed that store. The shared store's one remaining job is publishing
the open dashboard's dynamic variables for query-builder autocomplete,
which needs a name and an attribute.

Replace it with a suggestion feed and delete the rest, including the
panel variables prop that no GridCard caller passed and
useResolveQuery's dashboardData option that no caller supplied.
useGetResolvedText loses its only variable source and becomes the title
truncation its callers already used it for.



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

<!--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/326

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-04 06:31:38 +00:00
Abhi kumar
63b130ccd1 chore(codeowners): retarget the dashboard entries at their new paths (#12707)
#### Description

Post Dashboard v1 cleanup, codeowners update

#### Additional Information

Closes https://github.com/SigNoz/pulse-pod/issues/325
2026-09-04 05:28:03 +00:00
204 changed files with 8124 additions and 4966 deletions

28
.github/CODEOWNERS vendored
View File

@@ -152,39 +152,29 @@ go.mod @therealpandey
## Dashboard Types
/frontend/src/api/types/dashboard/ @SigNoz/pulse-frontend
/frontend/src/types/api/dashboard/ @SigNoz/pulse-frontend
/frontend/src/types/api/widgets/ @SigNoz/pulse-frontend
## Dashboard List
## Widget Card
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
# Dashboard Widget Page
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
## Dashboard Page
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
/frontend/src/container/WidgetCard/ @SigNoz/pulse-frontend
## Public Dashboard Page
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/visualization/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend
## Dashboard V2
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
## Dashboard Pages
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend

View File

@@ -44,6 +44,8 @@ import (
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/subscription/noopsubscription"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
@@ -87,6 +89,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return nooplicensing.NewFactory()
},
func(_ zeus.Zeus, _ licensing.Licensing) subscription.Subscription {
return noopsubscription.New()
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(config.Global),

View File

@@ -28,6 +28,7 @@ import (
eequerier "github.com/SigNoz/signoz/ee/querier"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/ee/subscription/httpsubscription"
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/alertmanager"
@@ -60,6 +61,7 @@ import (
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
@@ -103,6 +105,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
},
func(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription {
return httpsubscription.New(zeus, licensing)
},
signoz.NewEmailingProviderFactories(),
signoz.NewCacheProviderFactories(),
signoz.NewWebProviderFactories(config.Global),

View File

@@ -9217,6 +9217,116 @@ components:
required:
- references
type: object
SubscriptiontypesGettableSubscription:
properties:
redirectURL:
type: string
required:
- redirectURL
type: object
SubscriptiontypesGettableSubscriptionUsage:
properties:
billingPeriodEnd:
format: int64
type: integer
billingPeriodStart:
format: int64
type: integer
details:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDetails'
discount:
format: double
type: number
subscriptionStatus:
type: string
type: object
SubscriptiontypesPostableSubscription:
properties:
url:
type: string
required:
- url
type: object
SubscriptiontypesSubscriptionUsageBreakdown:
properties:
dayWiseBreakdown:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseBreakdown'
tiers:
items:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageTier'
nullable: true
type: array
type:
type: string
unit:
type: string
type: object
SubscriptiontypesSubscriptionUsageDayWiseBreakdown:
properties:
breakdown:
items:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseData'
nullable: true
type: array
type:
type: string
type: object
SubscriptiontypesSubscriptionUsageDayWiseData:
properties:
count:
format: double
type: number
quantity:
format: double
type: number
size:
format: double
type: number
timestamp:
format: int64
type: integer
total:
format: double
type: number
unitPrice:
format: double
type: number
type: object
SubscriptiontypesSubscriptionUsageDetails:
properties:
baseFee:
format: double
type: number
billTotal:
format: double
type: number
breakdown:
items:
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageBreakdown'
nullable: true
type: array
total:
format: double
type: number
type: object
SubscriptiontypesSubscriptionUsageTier:
properties:
quantity:
format: double
type: number
tierCost:
format: double
type: number
tierEnd:
format: int64
type: integer
tierStart:
format: int64
type: integer
unitPrice:
format: double
type: number
type: object
TagtypesGettableTag:
properties:
key:
@@ -14441,6 +14551,197 @@ paths:
summary: Get stats
tags:
- stats
/api/v1/subscriptions:
get:
deprecated: false
description: This endpoint gets the organization's subscription along with its
usage and billing details.
operationId: GetSubscription
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SubscriptiontypesGettableSubscriptionUsage'
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:
- subscription:read
- tokenizer:
- subscription:read
summary: Get the subscription.
tags:
- subscriptions
post:
deprecated: false
description: This endpoint creates a subscription for the organization.
operationId: CreateSubscription
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SubscriptiontypesPostableSubscription'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SubscriptiontypesGettableSubscription'
status:
type: string
required:
- status
- data
type: object
description: Created
"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
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- subscription:create
- tokenizer:
- subscription:create
summary: Create a subscription.
tags:
- subscriptions
put:
deprecated: false
description: This endpoint updates the organization's subscription.
operationId: UpdateSubscription
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SubscriptiontypesPostableSubscription'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SubscriptiontypesGettableSubscription'
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:
- subscription:list
- subscription:update
- tokenizer:
- subscription:list
- subscription:update
summary: Update the subscription.
tags:
- subscriptions
/api/v1/testChannel:
post:
deprecated: true

View File

@@ -0,0 +1,95 @@
package httpsubscription
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/subscription"
"github.com/SigNoz/signoz/pkg/types/subscriptiontypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/zeus"
"github.com/tidwall/gjson"
)
const upstreamTimeout = 10 * time.Second
type provider struct {
zeus zeus.Zeus
licensing licensing.Licensing
}
func New(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription {
return &provider{
zeus: zeus,
licensing: licensing,
}
}
func (provider *provider) Create(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) {
ctx, cancel := context.WithTimeout(ctx, upstreamTimeout)
defer cancel()
license, err := provider.licensing.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload")
}
response, err := provider.zeus.GetCheckoutURL(ctx, license.Key, body)
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Update(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) {
ctx, cancel := context.WithTimeout(ctx, upstreamTimeout)
defer cancel()
license, err := provider.licensing.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
body, err := json.Marshal(postableSubscription)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload")
}
response, err := provider.zeus.GetPortalURL(ctx, license.Key, body)
if err != nil {
return nil, err
}
return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
}
func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID) (*subscriptiontypes.GettableSubscriptionUsage, error) {
license, err := provider.licensing.GetActive(ctx, organizationID)
if err != nil {
return nil, err
}
data, err := provider.zeus.GetMeters(ctx, license.Key)
if err != nil {
return nil, err
}
usage, err := subscriptiontypes.NewGettableSubscriptionUsage(data)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal subscription usage")
}
return usage, nil
}

View File

@@ -323,9 +323,10 @@
"name": "react",
"importNames": [
"createContext",
"useContext"
"useContext",
"useSyncExternalStore"
],
"message": "[State mgmt] React Context is deprecated. Migrate shared state to Zustand."
"message": "[State mgmt] React Context and hand-rolled external stores are deprecated. Migrate shared state to Zustand."
},
{
"name": "immer",

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

@@ -10522,6 +10522,153 @@ export interface SpantypesUpdatableSpanMapperGroupDTO {
name?: string | null;
}
export interface SubscriptiontypesGettableSubscriptionDTO {
/**
* @type string
*/
redirectURL: string;
}
export interface SubscriptiontypesSubscriptionUsageDayWiseDataDTO {
/**
* @type number
* @format double
*/
count?: number;
/**
* @type number
* @format double
*/
quantity?: number;
/**
* @type number
* @format double
*/
size?: number;
/**
* @type integer
* @format int64
*/
timestamp?: number;
/**
* @type number
* @format double
*/
total?: number;
/**
* @type number
* @format double
*/
unitPrice?: number;
}
export interface SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO {
/**
* @type array,null
*/
breakdown?: SubscriptiontypesSubscriptionUsageDayWiseDataDTO[] | null;
/**
* @type string
*/
type?: string;
}
export interface SubscriptiontypesSubscriptionUsageTierDTO {
/**
* @type number
* @format double
*/
quantity?: number;
/**
* @type number
* @format double
*/
tierCost?: number;
/**
* @type integer
* @format int64
*/
tierEnd?: number;
/**
* @type integer
* @format int64
*/
tierStart?: number;
/**
* @type number
* @format double
*/
unitPrice?: number;
}
export interface SubscriptiontypesSubscriptionUsageBreakdownDTO {
dayWiseBreakdown?: SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO;
/**
* @type array,null
*/
tiers?: SubscriptiontypesSubscriptionUsageTierDTO[] | null;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
}
export interface SubscriptiontypesSubscriptionUsageDetailsDTO {
/**
* @type number
* @format double
*/
baseFee?: number;
/**
* @type number
* @format double
*/
billTotal?: number;
/**
* @type array,null
*/
breakdown?: SubscriptiontypesSubscriptionUsageBreakdownDTO[] | null;
/**
* @type number
* @format double
*/
total?: number;
}
export interface SubscriptiontypesGettableSubscriptionUsageDTO {
/**
* @type integer
* @format int64
*/
billingPeriodEnd?: number;
/**
* @type integer
* @format int64
*/
billingPeriodStart?: number;
details?: SubscriptiontypesSubscriptionUsageDetailsDTO;
/**
* @type number
* @format double
*/
discount?: number;
/**
* @type string
*/
subscriptionStatus?: string;
}
export interface SubscriptiontypesPostableSubscriptionDTO {
/**
* @type string
*/
url: string;
}
export type TelemetrytypesGettableFieldKeysDTOKeysAnyOf = {
[key: string]: TelemetrytypesTelemetryFieldKeyDTO[];
};
@@ -11740,6 +11887,30 @@ export type GetStats200 = {
status: string;
};
export type GetSubscription200 = {
data: SubscriptiontypesGettableSubscriptionUsageDTO;
/**
* @type string
*/
status: string;
};
export type CreateSubscription201 = {
data: SubscriptiontypesGettableSubscriptionDTO;
/**
* @type string
*/
status: string;
};
export type UpdateSubscription200 = {
data: SubscriptiontypesGettableSubscriptionDTO;
/**
* @type string
*/
status: string;
};
export type GetTraceAggregationsPathParameters = {
traceID: string;
};

View File

@@ -0,0 +1,280 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
CreateSubscription201,
GetSubscription200,
RenderErrorResponseDTO,
SubscriptiontypesPostableSubscriptionDTO,
UpdateSubscription200,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint gets the organization's subscription along with its usage and billing details.
* @summary Get the subscription.
*/
export const getSubscription = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetSubscription200>({
url: `/api/v1/subscriptions`,
method: 'GET',
signal,
});
};
export const getGetSubscriptionQueryKey = () => {
return [`/api/v1/subscriptions`] as const;
};
export const getGetSubscriptionQueryOptions = <
TData = Awaited<ReturnType<typeof getSubscription>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSubscription>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSubscriptionQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSubscription>>> = ({
signal,
}) => getSubscription(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getSubscription>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSubscriptionQueryResult = NonNullable<
Awaited<ReturnType<typeof getSubscription>>
>;
export type GetSubscriptionQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get the subscription.
*/
export function useGetSubscription<
TData = Awaited<ReturnType<typeof getSubscription>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSubscription>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSubscriptionQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get the subscription.
*/
export const invalidateGetSubscription = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSubscriptionQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoint creates a subscription for the organization.
* @summary Create a subscription.
*/
export const createSubscription = (
subscriptiontypesPostableSubscriptionDTO?: BodyType<SubscriptiontypesPostableSubscriptionDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateSubscription201>({
url: `/api/v1/subscriptions`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: subscriptiontypesPostableSubscriptionDTO,
signal,
});
};
export const getCreateSubscriptionMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
const mutationKey = ['createSubscription'];
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 createSubscription>>,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> }
> = (props) => {
const { data } = props ?? {};
return createSubscription(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateSubscriptionMutationResult = NonNullable<
Awaited<ReturnType<typeof createSubscription>>
>;
export type CreateSubscriptionMutationBody =
| BodyType<SubscriptiontypesPostableSubscriptionDTO>
| undefined;
export type CreateSubscriptionMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create a subscription.
*/
export const useCreateSubscription = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
return useMutation(getCreateSubscriptionMutationOptions(options));
};
/**
* This endpoint updates the organization's subscription.
* @summary Update the subscription.
*/
export const updateSubscription = (
subscriptiontypesPostableSubscriptionDTO?: BodyType<SubscriptiontypesPostableSubscriptionDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateSubscription200>({
url: `/api/v1/subscriptions`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: subscriptiontypesPostableSubscriptionDTO,
signal,
});
};
export const getUpdateSubscriptionMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
const mutationKey = ['updateSubscription'];
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 updateSubscription>>,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> }
> = (props) => {
const { data } = props ?? {};
return updateSubscription(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateSubscriptionMutationResult = NonNullable<
Awaited<ReturnType<typeof updateSubscription>>
>;
export type UpdateSubscriptionMutationBody =
| BodyType<SubscriptiontypesPostableSubscriptionDTO>
| undefined;
export type UpdateSubscriptionMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update the subscription.
*/
export const useUpdateSubscription = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateSubscription>>,
TError,
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
TContext
> => {
return useMutation(getUpdateSubscriptionMutationOptions(options));
};

View File

@@ -6,6 +6,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty } from 'lodash-es';
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -545,20 +546,22 @@ function reduceQueriesToObject(queryArray: any[]): {
/**
* Prepares V5 query range payload from GetQueryResultsProps
*/
export const prepareQueryRangePayloadV5 = ({
query,
globalSelectedInterval,
graphType,
selectedTime,
tableParams,
variables = {},
start: startTime,
end: endTime,
formatForWeb,
originalGraphType,
fillGaps,
dynamicVariables,
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
export const prepareQueryRangePayloadV5 = (
{
query,
globalSelectedInterval,
graphType,
selectedTime,
tableParams,
variables = {},
start: startTime,
end: endTime,
formatForWeb,
originalGraphType,
fillGaps,
}: GetQueryResultsProps,
dynamicVariables: DynamicVariableSuggestion[] = [],
): PrepareQueryRangePayloadV5Result => {
let legendMap: Record<string, string> = {};
const requestType = mapPanelTypeToRequestType(graphType);
let queries: QueryEnvelope[] = [];
@@ -671,9 +674,9 @@ export const prepareQueryRangePayloadV5 = ({
(acc, [key, value]) => {
acc[key] = {
value,
type: dynamicVariables
?.find((v) => v.name === key)
?.type?.toLowerCase() as VariableType,
type: dynamicVariables.some((v) => v.name === key)
? ('dynamic' as VariableType)
: undefined,
};
return acc;
},

View File

@@ -0,0 +1,47 @@
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

@@ -27,7 +27,7 @@ import {
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
@@ -258,10 +258,7 @@ function QuerySearch({
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
@@ -1188,8 +1185,8 @@ function QuerySearch({
);
// Add dynamic variables suggestions for the current key
const variableName = dashboardDynamicVariables?.find(
(variable) => variable?.dynamicVariablesAttribute === keyName,
const variableName = dashboardDynamicVariables.find(
(variable) => variable.attribute === keyName,
)?.name;
if (variableName) {

View File

@@ -525,6 +525,34 @@ export const convertFiltersToExpressionWithExistingQuery = (
};
};
/**
* Canonical name for a comparison's operator, limited to the equality and
* membership forms. Every other shape (LIKE, BETWEEN, EXISTS, CONTAINS, REGEXP,
* the ordering operators) returns undefined, so an operator-restricted removal
* leaves it in place.
*
* The ANTLR4 runtime returns null for an absent token or rule despite the
* non-nullable TypeScript signatures.
*/
const getComparisonOperator = (ctx: ComparisonContext): string | undefined => {
if ((ctx.inClause() as unknown) !== null) {
return 'in';
}
if ((ctx.notInClause() as unknown) !== null) {
return 'not in';
}
if ((ctx.EQUALS() as unknown) !== null) {
return '=';
}
if (
(ctx.NOT_EQUALS() as unknown) !== null ||
(ctx.NEQ() as unknown) !== null
) {
return '!=';
}
return undefined;
};
/**
* Removes clauses for specified keys from a filter query expression.
*
@@ -542,12 +570,16 @@ export const convertFiltersToExpressionWithExistingQuery = (
* - `true`: removes only the first clause whose value contains any `$`.
* - `string` (e.g. `"$service.name"`): removes only the clause whose value exactly
* matches that string — preferred when the specific variable reference is known.
* @param operatorsToRemove - When given, restricts removal to clauses whose operator
* is in this set (`=`, `!=`, `in`, `not in`); every other clause on the key is kept.
* Omit to remove a matching key's clauses whatever their operator.
* @returns The rewritten expression, or an empty string if all clauses were removed.
*/
export const removeKeysFromExpression = (
expression: string,
keysToRemove: string[],
removeOnlyVariableExpressions: string | boolean = false,
operatorsToRemove?: string[],
): string => {
if (!keysToRemove || keysToRemove.length === 0) {
return expression;
@@ -557,6 +589,9 @@ export const removeKeysFromExpression = (
}
const keysSet = new Set(keysToRemove.map((k) => k.trim().toLowerCase()));
const operatorsSet = operatorsToRemove
? new Set(operatorsToRemove.map((op) => op.trim().toLowerCase()))
: null;
// Tracks keys for which a variable expression has already been removed.
// Having multiple $-value clauses for the same key is invalid; we remove at most one.
const removedVariableKeys = new Set<string>();
@@ -658,6 +693,13 @@ export const removeKeysFromExpression = (
return src(ctx);
}
if (operatorsSet) {
const operator = getComparisonOperator(ctx);
if (!operator || !operatorsSet.has(operator)) {
return src(ctx);
}
}
if (removeOnlyVariableExpressions) {
// Scope the value check to value nodes only — not the full comparison text —
// so a key that contains '$' does not trigger removal when the value is a

View File

@@ -0,0 +1,526 @@
import {
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
} from 'components/QueryBuilderV2/utils';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import {
Query,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
applyCheckboxToggle,
clearFilterFromQuery,
deriveCheckboxState,
getNotInOperator,
} from './checkboxFilterQuery';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
const KEY = 'service.name';
/**
* Mini test framework
* -------------------
* `filters.items` is the source of truth the checkbox algebra mutates.
* `filter.expression` is the derived value the backend actually reads, and it is
* authoritatively rebuilt from the items on every URL round trip
* (`useGetCompositeQueryParam` -> `convertFiltersToExpressionWithExistingQuery`).
* That rebuild is additive, so `applyCheckboxToggle` re-derives its own clauses
* into the expression itself: otherwise the round trip resurrects a clause the
* toggle removed, or appends a duplicate of one it replaced.
*
* So a case does not assert the intermediate expression the toggle emits. It
* asserts the pair that has to stay consistent:
* - `items` : exact structured clauses after the toggle
* - `expression` : the expression AFTER the round trip, which is what ships
*
* `runToggle` runs the real reducer, then feeds its output through the real
* converter to get the shipped expression.
*/
type SimpleItem = {
key: string;
op: string;
value: TagFilterItem['value'];
};
function toTagItem(item: SimpleItem, idx: number): TagFilterItem {
return {
id: `id-${idx}`,
key: { key: item.key, type: 'tag' } as TagFilterItem['key'],
op: item.op,
value: item.value,
};
}
// Serialises items into an expression (via the app's own converter) so a case's
// starting state is self-consistent (items and expression agree), the way it
// would be in the app after a prior round trip.
const serializeItems = (items: SimpleItem[]): string =>
convertFiltersToExpression({ items: items.map(toTagItem), op: 'AND' })
.expression;
function buildQuery(items: SimpleItem[], expression: string): Query {
return {
builder: {
queryData: [
{
filters: { items: items.map(toTagItem), op: 'AND' },
filter: { expression },
},
],
},
} as unknown as Query;
}
// Simulates the URL round trip: rebuild the shipped expression from the items,
// reconciled against whatever expression the toggle left behind. Trimmed to
// absorb a converter quirk that leaves a trailing space when it widens an
// operator in place (e.g. `=` -> `IN`).
function roundTripExpression(
items: TagFilterItem[],
emittedExpression: string,
): string {
const filters: TagFilter = { items, op: 'AND' };
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
emittedExpression,
);
return (filter?.expression ?? '').trim();
}
interface ToggleAction {
value: string;
checked: boolean;
isOnlyOrAllClicked?: boolean;
previousState?: CheckedState;
sectionType?: SectionType;
source?: QuickFiltersSource;
attributeValues?: string[];
}
interface ToggleCase {
name: string;
initial?: { items?: SimpleItem[]; expression?: string };
action: ToggleAction;
expected: { items: SimpleItem[]; expression: string };
}
function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
const initialItems = c.initial?.items ?? [];
const initialExpression =
c.initial?.expression ?? serializeItems(initialItems);
const result = applyCheckboxToggle({
currentQuery: buildQuery(initialItems, initialExpression),
activeQueryIndex: 0,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
value: c.action.value,
checked: c.action.checked,
isOnlyOrAllClicked: c.action.isOnlyOrAllClicked ?? false,
previousState: c.action.previousState,
sectionType: c.action.sectionType,
});
const active = result.builder.queryData[0];
const items = active?.filters?.items ?? [];
return {
items: items.map((item) => ({
key: item.key?.key ?? '',
op: item.op,
value: item.value,
})),
expression: roundTripExpression(items, active?.filter?.expression ?? ''),
};
}
// Flat list. Every row asserts both the structured items and the shipped
// (round-tripped) expression, which must stay in sync.
const TOGGLE_CASES: ToggleCase[] = [
{
name: 'no clause, checked -> IN',
action: { value: 'a', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `service.name in ['a']`,
},
},
{
name: 'no clause, unchecked -> NOT IN',
action: { value: 'a', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: 'a' }],
expression: `service.name not in ['a']`,
},
},
{
name: 'no clause, unchecked on infra -> not in',
action: {
value: 'a',
checked: false,
source: QuickFiltersSource.INFRA_MONITORING,
},
// `nin` is what the source asks for, but re-deriving the expression
// normalises it. Nothing observes the difference: both infra pages send
// `filter.expression` and never `filters.items`.
expected: {
items: [{ key: KEY, op: 'not in', value: 'a' }],
expression: `service.name not in ['a']`,
},
},
{
name: 'IN, check another value -> appended',
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
{
name: 'IN, check when value is scalar -> promoted to array',
initial: { items: [{ key: KEY, op: 'in', value: 'a' }] },
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
{
name: 'IN, uncheck one of many -> filtered out',
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
action: { value: 'a', checked: false },
expected: {
items: [{ key: KEY, op: 'in', value: ['b'] }],
expression: `service.name in ['b']`,
},
},
{
name: 'IN, uncheck last value in array -> clause gone',
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: 'IN, uncheck scalar value -> clause gone',
initial: { items: [{ key: KEY, op: 'in', value: 'a' }] },
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: 'IN, uncheck in RELATED section -> replaced by NOT IN for that value',
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
action: { value: 'a', checked: false, sectionType: SectionType.RELATED },
expected: {
items: [{ key: KEY, op: 'not in', value: 'a' }],
expression: `service.name not in ['a']`,
},
},
{
name: 'NOT IN, was unchecked then checked -> replaced by IN for that value',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'b', checked: true, previousState: 'unchecked' },
expected: {
items: [{ key: KEY, op: 'in', value: 'b' }],
expression: `service.name in ['b']`,
},
},
{
name: 'NOT IN, re-checking an excluded value clears it, not flips it to IN',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'a', checked: true, previousState: 'unchecked' },
expected: { items: [], expression: '' },
},
{
name: 'NOT IN, re-checking one of several excluded values keeps the rest',
initial: { items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }] },
action: { value: 'a', checked: true, previousState: 'unchecked' },
expected: {
items: [{ key: KEY, op: 'not in', value: ['b'] }],
expression: `service.name not in ['b']`,
},
},
{
name: 'NOT IN, exclude another value -> appended',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'b', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: 'NOT IN, exclude when scalar -> promoted to array',
initial: { items: [{ key: KEY, op: 'not in', value: 'a' }] },
action: { value: 'b', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: 'NOT IN, check an excluded value -> removed from array',
initial: { items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }] },
action: { value: 'a', checked: true },
expected: {
items: [{ key: KEY, op: 'not in', value: ['b'] }],
expression: `service.name not in ['b']`,
},
},
{
name: 'NOT IN, check last excluded value in array -> clause gone',
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
action: { value: 'a', checked: true },
expected: { items: [], expression: '' },
},
{
name: 'NOT IN, check excluded scalar value -> clause gone',
initial: { items: [{ key: KEY, op: 'not in', value: 'a' }] },
action: { value: 'a', checked: true },
expected: { items: [], expression: '' },
},
{
name: '= check another value -> promoted to IN array',
initial: { items: [{ key: KEY, op: '=', value: 'a' }] },
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
{
name: '= uncheck -> clause gone',
initial: { items: [{ key: KEY, op: '=', value: 'a' }] },
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: '!= exclude another value -> promoted to NOT IN array',
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
action: { value: 'b', checked: false },
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: '!= exclude another value on infra -> not in array',
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
action: {
value: 'b',
checked: false,
source: QuickFiltersSource.INFRA_MONITORING,
},
expected: {
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
expression: `service.name not in ['a', 'b']`,
},
},
{
name: '!= check -> clause gone',
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
action: { value: 'a', checked: true },
expected: { items: [], expression: '' },
},
{
name: 'Only with no clause -> IN scalar',
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
expected: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `service.name in ['a']`,
},
},
{
name: 'Only replaces a multi-value IN with a single value',
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
expected: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `service.name in ['a']`,
},
},
{
name: 'All (clicking the sole selected value) -> clause gone',
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
expected: { items: [], expression: '' },
},
{
name: 'dropping the last clause keeps other keys in the expression',
initial: {
items: [{ key: KEY, op: 'in', value: 'a' }],
expression: `${KEY} = 'a' AND http.method = 'GET'`,
},
action: { value: 'a', checked: false },
// The seeded items omit the http.method clause the expression carries;
// re-deriving reconciles it back, which is why items is not empty here.
expected: {
items: [{ key: 'http.method', op: '=', value: 'GET' }],
expression: `http.method = 'GET'`,
},
},
{
name: 'dropping the last clause strips the prefixed spelling too',
initial: {
items: [{ key: 'resource.service.name', op: 'in', value: 'a' }],
expression: `resource.service.name = 'a'`,
},
action: { value: 'a', checked: false },
expected: { items: [], expression: '' },
},
{
name: 'removing the value must keep a free-form clause on the same key',
initial: {
items: [{ key: KEY, op: '=', value: 'a' }],
expression: `${KEY} = 'a' AND ${KEY} CONTAINS 'keepme'`,
},
action: { value: 'a', checked: false },
expected: {
items: [{ key: KEY, op: 'contains', value: 'keepme' }],
expression: `service.name CONTAINS 'keepme'`,
},
},
{
name: 'a second clause on the same key must not survive an add',
initial: {
items: [{ key: KEY, op: 'in', value: ['a'] }],
expression: `${KEY} IN ['a'] AND ${KEY} != 'z'`,
},
action: { value: 'b', checked: true },
expected: {
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
expression: `service.name in ['a', 'b']`,
},
},
];
describe('applyCheckboxToggle (items + shipped expression stay in sync)', () => {
it.each(TOGGLE_CASES)('$name', (c) => {
const got = runToggle(c);
expect(got.items).toStrictEqual(c.expected.items);
expect(got.expression).toBe(c.expected.expression);
});
});
describe('getNotInOperator', () => {
it('returns short "nin" for infra monitoring', () => {
expect(getNotInOperator(QuickFiltersSource.INFRA_MONITORING)).toBe('nin');
});
it('returns long "not in" for other sources', () => {
expect(getNotInOperator(QuickFiltersSource.LOGS_EXPLORER)).toBe('not in');
expect(getNotInOperator(QuickFiltersSource.TRACES_EXPLORER)).toBe('not in');
});
});
describe('deriveCheckboxState', () => {
const attributeValues = ['a', 'b', 'c'];
const state = (items: TagFilterItem[] | undefined): Record<string, boolean> =>
deriveCheckboxState({ attributeValues, filterItems: items, filterKey: KEY });
it('no clause for key -> everything checked', () => {
expect(state([])).toStrictEqual({ a: true, b: true, c: true });
expect(state(undefined)).toStrictEqual({ a: true, b: true, c: true });
});
it('unrelated clause only -> everything checked', () => {
expect(
state([toTagItem({ key: 'other', op: 'in', value: ['a'] }, 0)]),
).toStrictEqual({ a: true, b: true, c: true });
});
it('IN [list] -> only listed values checked', () => {
expect(
state([toTagItem({ key: KEY, op: 'in', value: ['a', 'c'] }, 0)]),
).toStrictEqual({ a: true, b: false, c: true });
});
it('= "value" -> only that value checked', () => {
expect(
state([toTagItem({ key: KEY, op: '=', value: 'b' }, 0)]),
).toStrictEqual({ a: false, b: true, c: false });
});
it('NOT IN [list] -> everything except excluded checked', () => {
expect(
state([toTagItem({ key: KEY, op: 'not in', value: ['a'] }, 0)]),
).toStrictEqual({ a: false, b: true, c: true });
});
it('!= "value" -> everything except that value checked', () => {
expect(
state([toTagItem({ key: KEY, op: '!=', value: 'b' }, 0)]),
).toStrictEqual({ a: true, b: false, c: true });
});
it('matches by base key across context prefixes', () => {
expect(
state([
toTagItem({ key: 'resource.service.name', op: 'in', value: ['a'] }, 0),
]),
).toStrictEqual({ a: true, b: false, c: false });
});
it('coerces boolean / number values to string keys', () => {
expect(
deriveCheckboxState({
attributeValues: ['true', '42'],
filterItems: [toTagItem({ key: KEY, op: '=', value: true }, 0)],
filterKey: KEY,
}),
).toStrictEqual({ true: true, '42': false });
});
});
describe('clearFilterFromQuery', () => {
it('removes the key from items and expression at the active index only', () => {
const query = {
builder: {
queryData: [
{
filters: {
items: [
toTagItem({ key: KEY, op: 'in', value: ['a'] }, 0),
toTagItem({ key: 'http.method', op: '=', value: 'GET' }, 1),
],
op: 'AND',
},
filter: { expression: `${KEY} = 'a' AND http.method = 'GET'` },
},
{
filters: {
items: [toTagItem({ key: KEY, op: 'in', value: ['a'] }, 2)],
op: 'AND',
},
filter: { expression: `${KEY} = 'a'` },
},
],
},
} as unknown as Query;
const result = clearFilterFromQuery({
currentQuery: query,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
activeQueryIndex: 0,
});
const active = result.builder.queryData[0];
expect(active.filters?.items).toStrictEqual([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
}),
]);
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
// Other queries keep both halves: stripping their expression while leaving
// their items alone only churned a clause the round trip put straight back.
const other = result.builder.queryData[1];
expect(other.filters?.items).toHaveLength(1);
expect(other.filter?.expression).toBe(`${KEY} = 'a'`);
});
});

View File

@@ -1,5 +1,8 @@
/* eslint-disable sonarjs/no-identical-functions */
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
convertFiltersToExpressionWithExistingQuery,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import {
IQuickFiltersConfig,
QuickFiltersSource,
@@ -10,13 +13,33 @@ import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { isKeyMatch } from './utils';
import { getKeySpellings, isKeyMatch } from './utils';
import { CheckedState } from '../../types';
import { SectionType } from './v2/itemRules';
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
// The operators this algebra emits, and so the only ones it may rewrite out of an
// expression. A hand-written clause on the same key (CONTAINS, EXISTS, a range) is
// none of its business and has to survive a toggle.
const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
/**
* Drops this filter's own clauses for `key` from `expression`, leaving every other
* key and any clause the checkbox does not manage untouched. Matches all context
* prefixes, since `isKeyMatch` treats `service.name` and `resource.service.name` as
* the same filter but expression rewrites match keys literally.
*/
function removeManagedClauses(expression: string, key: string): string {
return removeKeysFromExpression(
expression,
getKeySpellings(key),
false,
MANAGED_OPERATORS,
);
}
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
@@ -102,8 +125,8 @@ export function deriveCheckboxState({
}
/**
* Returns a new query with every clause for this attribute key removed, both
* from the structured filter items and the raw filter expression.
* Returns a new query with this filter's clauses for the attribute key removed from
* the active query, both from the structured filter items and the raw expression.
*/
export function clearFilterFromQuery({
currentQuery,
@@ -118,24 +141,28 @@ export function clearFilterFromQuery({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filter: {
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
filter.attributeKey.key,
]),
},
filters: {
...item.filters,
items:
idx === activeQueryIndex
? item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || []
: [...(item.filters?.items || [])],
op: item.filters?.op || 'AND',
},
})),
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
expression: removeManagedClauses(
item.filter?.expression ?? '',
filter.attributeKey.key,
),
},
filters: {
...item.filters,
items:
item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || [],
op: item.filters?.op || 'AND',
},
};
}),
},
};
}
@@ -194,12 +221,6 @@ export function applyCheckboxToggle({
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
filter.attributeKey.key,
]);
}
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
@@ -267,12 +288,6 @@ export function applyCheckboxToggle({
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (isArray(currentFilter.value)) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
@@ -309,9 +324,10 @@ export function applyCheckboxToggle({
? currentFilter.value.includes(value)
: currentFilter.value === value;
// When clicking unchecked "Other" item, user wants to SELECT it
// Replace NOT IN filter with IN [value]
if (previousState === 'unchecked' && checked) {
// When clicking an unchecked value that is not itself excluded, the user
// wants to SELECT it: replace the NOT IN filter with IN [value]. A value
// that IS in the exclusion list falls through to the removal branch below.
if (previousState === 'unchecked' && checked && !isValueInFilter) {
const newFilter: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
@@ -324,12 +340,6 @@ export function applyCheckboxToggle({
}
return item;
});
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else if (!checked || !isValueInFilter) {
// Add to NOT IN when:
// - checked=false (user explicitly unchecked to exclude)
@@ -369,12 +379,6 @@ export function applyCheckboxToggle({
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
} else {
query.filters.items = query.filters.items.map((item) => {
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
@@ -384,16 +388,6 @@ export function applyCheckboxToggle({
});
}
} else {
const newFilter = {
...currentFilter,
value: currentFilter.value === value ? null : currentFilter.value,
};
if (newFilter.value === null && query.filter?.expression) {
query.filter.expression = removeKeysFromExpression(
query.filter.expression,
[filter.attributeKey.key],
);
}
query.filters.items = query.filters.items.filter(
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
);
@@ -456,6 +450,18 @@ export function applyCheckboxToggle({
}
}
if (query) {
const synced = convertFiltersToExpressionWithExistingQuery(
query.filters ?? { items: [], op: 'AND' },
removeManagedClauses(
query.filter?.expression ?? '',
filter.attributeKey.key,
),
);
query.filter = synced.filter;
query.filters = synced.filters;
}
return {
...currentQuery,
builder: {

View File

@@ -39,3 +39,16 @@ export function isKeyMatch(
): boolean {
return getKeyWithoutPrefix(itemKey) === getKeyWithoutPrefix(filterKey);
}
/**
* Every spelling of a key that `isKeyMatch` treats as equal: the base name plus
* each context-prefixed form. Expression rewrites match keys literally, so they
* need the whole list where the items side only needs `isKeyMatch`.
*/
export function getKeySpellings(key: string | undefined): string[] {
const base = getKeyWithoutPrefix(key);
if (!base) {
return [];
}
return [base, ...FIELD_CONTEXT_PREFIXES.map((prefix) => `${prefix}.${base}`)];
}

View File

@@ -1,5 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -76,7 +75,7 @@ export function buildEntityMetricsChartConfig({
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
});
builder.addAxis({
@@ -85,7 +84,6 @@ export function buildEntityMetricsChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {

View File

@@ -1,5 +1,4 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
@@ -72,7 +71,7 @@ export function buildMeterChartConfig({
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.BAR,
isTimeAxis: true,
});
builder.addAxis({
@@ -81,7 +80,6 @@ export function buildMeterChartConfig({
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {

View File

@@ -21,7 +21,7 @@ import {
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
@@ -263,10 +263,7 @@ function QueryBuilderSearchV2(
return false;
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const { data, isFetching } = useGetAggregateKeys(
{
@@ -816,9 +813,8 @@ function QueryBuilderSearchV2(
values.push(...(attributeValues?.payload?.[key] || []));
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
const variableName = dashboardDynamicVariables?.find(
(variable) =>
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
const variableName = dashboardDynamicVariables.find(
(variable) => variable.attribute === currentFilterItem?.key?.key,
)?.name;
if (variableName) {

View File

@@ -5,9 +5,8 @@ import {
initialQueriesMap,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { IUseDashboardVariablesReturn } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -150,24 +149,14 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
// Mock dashboard variables
const dashboardVariables = {
service: {
id: 'service',
name: 'service',
type: 'DYNAMIC' as IDashboardVariable['type'],
dynamicVariablesAttribute: 'service.name',
description: '',
sort: 'DISABLED' as IDashboardVariable['sort'],
multiSelect: false,
showALLOption: false,
},
};
// Mock the dynamic variables the open dashboard would publish
const dynamicVariableSuggestions = [
{ name: 'service', attribute: 'service.name' },
];
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): IUseDashboardVariablesReturn => ({
dashboardVariables: dashboardVariables,
}),
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
useDynamicVariableSuggestions: (): DynamicVariableSuggestion[] =>
dynamicVariableSuggestions,
}));
describe('Suggestion Key -> Operator -> Value Flow', () => {

View File

@@ -2,7 +2,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { ServicesList } from 'types/api/metrics/getService';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
@@ -47,7 +46,6 @@ export const getQueryRangeRequestData = ({
graphType: serviceMetricsWidget?.panelTypes,
query: updatedQuery,
globalSelectedInterval,
variables: getDashboardVariables(),
});
});
return requestData;

View File

@@ -28,13 +28,11 @@ import { populateMultipleResults } from 'lib/query/populateMultipleResults';
import { timeItems, timePreferance } from 'constants/timePreference';
import PanelWrapper from 'container/WidgetCard/Panels/PanelWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useChartMutable } from 'hooks/useChartMutable';
import useUrlQuery from 'hooks/useUrlQuery';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import GetMinMax from 'lib/getMinMax';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
@@ -82,8 +80,6 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { dashboardVariables } = useDashboardVariables();
const getSelectedTime = useCallback(
() =>
timeItems.find((e) => e.enum === (widget?.timePreferance || 'GLOBAL_TIME')),
@@ -115,7 +111,6 @@ function FullView({
graphType: getGraphType(selectedPanelType),
query: updatedQuery,
globalSelectedInterval: globalSelectedTime,
variables: getDashboardVariables(dashboardVariables),
fillGaps: widget.fillSpans,
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
originalGraphType: selectedPanelType,
@@ -126,7 +121,6 @@ function FullView({
graphType: PANEL_TYPES.LIST,
selectedTime: widget?.timePreferance || 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
variables: getDashboardVariables(dashboardVariables),
tableParams: {
pagination: {
offset: 0,

View File

@@ -8,12 +8,9 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { useScrollWidgetIntoView } from 'lib/visualization/hooks/useScrollWidgetIntoView';
import { populateMultipleResults } from 'lib/query/populateMultipleResults';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { getVariableReferencesInQuery } from 'lib/dashboardVariables/variableReference';
import getTimeString from 'lib/getTimeString';
import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
@@ -45,7 +42,6 @@ function GridCardGraph({
headerMenuList = [MenuItemKeys.View],
isQueryEnabled,
threshold,
variables,
version,
onClickHandler,
onDragSelect,
@@ -113,25 +109,10 @@ function GridCardGraph({
const updatedQuery = widget?.query;
const referencedVariableNames = useMemo(() => {
if (!variables || !updatedQuery) {
return [];
}
const allNames = Object.values(variables)
.map((v) => v.name)
.filter((name): name is string => !!name);
return getVariableReferencesInQuery(updatedQuery, allNames);
}, [updatedQuery, variables]);
const isEmptyWidget =
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
const isPanelWaitingOnAnyVariable = useIsPanelWaitingOnVariable(
referencedVariableNames,
);
const queryEnabledCondition =
isVisible && !isEmptyWidget && isQueryEnabled && !isPanelWaitingOnAnyVariable;
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
@@ -140,7 +121,6 @@ function GridCardGraph({
graphType: getGraphType(widget.panelTypes),
query: updatedQuery,
globalSelectedInterval,
variables: getDashboardVariables(variables),
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: customTimeRange?.startTime || start,
@@ -191,7 +171,6 @@ function GridCardGraph({
const queryResponse = useGetQueryRange(
{
...requestData,
variables: getDashboardVariables(variables),
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
globalSelectedInterval:
widget?.panelTypes === PANEL_TYPES.LIST && isLogsQuery
@@ -214,14 +193,6 @@ function GridCardGraph({
widget.timePreferance,
widget.fillSpans,
requestData,
variables
? Object.entries(variables).reduce((acc, [id, variable]) => {
if (variable.name && referencedVariableNames.includes(variable.name)) {
return { ...acc, [id]: variable.selectedValue };
}
return acc;
}, {})
: {},
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),
@@ -303,9 +274,7 @@ function GridCardGraph({
version={version}
threshold={threshold}
headerMenuList={menuList}
isFetchingResponse={
queryResponse.isFetching || isPanelWaitingOnAnyVariable
}
isFetchingResponse={queryResponse.isFetching}
setRequestData={setRequestData}
onClickHandler={onClickHandler}
onDragSelect={onDragSelect}

View File

@@ -4,7 +4,6 @@ import { ToggleGraphProps } from 'components/Graph/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { Widgets } from 'types/api/widgets/widget';
import {
MetricQueryRangeSuccessResponse,
@@ -52,7 +51,6 @@ export interface GridCardGraphProps {
headerMenuList?: WidgetGraphComponentProps['headerMenuList'];
onClickHandler?: OnClickPluginOpts['onClick'];
isQueryEnabled: boolean;
variables?: IDashboardVariables;
version?: string;
onDragSelect: (start: number, end: number) => void;
customOnDragSelect?: (start: number, end: number) => void;

View File

@@ -26,8 +26,8 @@ jest.mock(
}),
);
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
useDynamicVariableSuggestions: (): unknown[] => mockDynamicVariables,
}));
jest.mock('react-redux', () => ({
@@ -64,11 +64,12 @@ describe('useResolveQuery', () => {
expect(resolved).toBe(QUERY);
});
it('resolves through substitute_vars when the dashboard has variables', async () => {
it('resolves through substitute_vars when the dashboard has dynamic variables', async () => {
mockGetSubstituteVars.mockResolvedValue({
httpStatusCode: 200,
data: { compositeQuery: {} },
});
mockDynamicVariables.push({ name: 'env', attribute: 'deployment.env' });
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
@@ -76,13 +77,6 @@ describe('useResolveQuery', () => {
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
dashboardData: {
data: {
variables: {
env: { name: 'env', selectedValue: 'prod' },
},
},
},
});
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);

View File

@@ -7,8 +7,7 @@ import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'constants/timePreference';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -21,7 +20,6 @@ interface UseUpdatedQueryOptions {
panelTypes: PANEL_TYPES;
timePreferance: timePreferenceType;
};
dashboardData?: any;
}
interface UseUpdatedQueryResult {
@@ -37,34 +35,27 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const queryRangeMutation = useMutation(getSubstituteVars);
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
const variables = getDashboardVariables(dashboardData?.data?.variables);
async ({ widgetConfig }: UseUpdatedQueryOptions): Promise<Query> => {
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
if (isEmpty(dashboardDynamicVariables)) {
return widgetConfig.query;
}
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables,
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});
const { queryPayload } = prepareQueryRangePayloadV5(
{
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
originalGraphType: widgetConfig.panelTypes,
},
dashboardDynamicVariables,
);
// Execute query and process results
const queryResult = await queryRangeMutation.mutateAsync(queryPayload);

View File

@@ -1,242 +1,40 @@
import React from 'react';
import { renderHook } from '@testing-library/react';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import useGetResolvedText from '../useGetResolvedText';
// Create a mock function that we can modify per test
let mockDashboardVariables: IDashboardVariables = {};
// Mock the useDashboardVariables hook
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: jest.fn(() => ({
dashboardVariables: mockDashboardVariables,
})),
}));
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
describe('useGetResolvedText', () => {
const SERVICE_VAR = 'test, app +2-|-test, app, frontend, env';
const SEVERITY_VAR = 'DEBUG, INFO-|-DEBUG, INFO';
const EXPECTED_FULL_TEXT =
'Logs count in test, app, frontend, env in DEBUG, INFO';
const TRUNCATED_SERVICE = 'test, app +2';
const TEXT_TEMPLATE = 'Logs count in $service.name in $severity';
const renderHookWithProps = (
props: {
text: string | React.ReactNode;
maxLength?: number;
matcher?: string;
},
variables?: Record<string, string | number | boolean>,
): any => {
if (variables) {
mockDashboardVariables = Object.entries(
variables,
).reduce<IDashboardVariables>((acc, [key, value]) => {
acc[key] = {
id: key,
name: key,
description: '',
type: 'CUSTOM' as const,
sort: 'DISABLED' as const,
multiSelect: false,
showALLOption: false,
selectedValue: value,
};
return acc;
}, {});
} else {
mockDashboardVariables = {};
}
return renderHook(() => useGetResolvedText(props));
};
it('should resolve variables with truncated and full text', () => {
const text = TEXT_TEMPLATE;
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
`Logs count in ${TRUNCATED_SERVICE} in DEBUG, INFO`,
it('returns the text unchanged when it fits within maxLength', () => {
const { result } = renderHook(() =>
useGetResolvedText({ text: 'Logs count', maxLength: 100 }),
);
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
expect(result.current.fullText).toBe('Logs count');
expect(result.current.truncatedText).toBe('Logs count');
});
it('should handle text with maxLength truncation', () => {
const text = TEXT_TEMPLATE;
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
it('returns the text unchanged when no maxLength is given', () => {
const text = 'a'.repeat(200);
const { result } = renderHook(() => useGetResolvedText({ text }));
const { result } = renderHookWithProps({ text, maxLength: 20 }, variables);
expect(result.current.truncatedText).toBe('Logs count in test, a...');
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle multiple occurrences of the same variable', () => {
const text = 'Logs count in $service.name and $service.name';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 and test, app +2',
);
expect(result.current.fullText).toBe(
'Logs count in test, app, frontend, env and test, app, frontend, env',
);
});
it('should handle different variable formats', () => {
const text =
'Logs in $service.name, {{service.name}}, [[service.name]] - $dyn-service.name';
const variables = {
'service.name': SERVICE_VAR,
'$dyn-service.name': 'dyn-1, dyn-2',
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs in test, app +2, test, app +2, test, app +2 - dyn-1, dyn-2',
);
expect(result.current.fullText).toBe(
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env - dyn-1, dyn-2',
);
});
it('should handle custom matcher', () => {
const text = 'Logs count in #service.name in #severity';
const variables = {
'service.name': SERVICE_VAR,
severity: SEVERITY_VAR,
};
const { result } = renderHookWithProps({ text, matcher: '#' }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in DEBUG, INFO',
);
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
});
it('should handle non-string variable values', () => {
const text = 'Count: $count, Active: $active';
const variables = {
count: 42,
active: true,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('Count: 42, Active: true');
expect(result.current.truncatedText).toBe('Count: 42, Active: true');
});
it('should keep original text for undefined variables', () => {
const text = 'Logs count in $service.name in $unknown';
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.truncatedText).toBe(
'Logs count in test, app +2 in $unknown',
);
expect(result.current.fullText).toBe(
'Logs count in test, app, frontend, env in $unknown',
);
});
it('should handle non-string text input (ReactNode)', () => {
const reactNodeText = <div>Test ReactNode</div>;
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps(
{
text: reactNodeText,
},
variables,
);
// Should return the ReactNode unchanged
expect(result.current.fullText).toBe(reactNodeText);
expect(result.current.truncatedText).toBe(reactNodeText);
});
it('should handle number input', () => {
const text = 123;
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps(
{
text,
},
variables,
);
// Should return the number unchanged
expect(result.current.fullText).toBe(text);
expect(result.current.truncatedText).toBe(text);
});
it('should handle boolean input', () => {
const text = true;
const variables = {
'service.name': SERVICE_VAR,
};
const { result } = renderHookWithProps(
{
text,
},
variables,
it('truncates to maxLength with an ellipsis and keeps the full text', () => {
const { result } = renderHook(() =>
useGetResolvedText({ text: 'Logs count in production', maxLength: 20 }),
);
// Should return the boolean unchanged
expect(result.current.fullText).toBe(text);
expect(result.current.truncatedText).toBe(text);
expect(result.current.truncatedText).toBe('Logs count in pro...');
expect(result.current.truncatedText).toHaveLength(20);
expect(result.current.fullText).toBe('Logs count in production');
});
it('should handle complex variable names with improved patterns', () => {
const text = 'API: $api.v1.endpoint Config: $config.database.host';
const variables = {
'api.v1.endpoint': '/users',
'config.database.host': 'localhost:5432',
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('API: /users Config: localhost:5432');
expect(result.current.truncatedText).toBe(
'API: /users Config: localhost:5432',
it('passes non-string content through untouched', () => {
const node = <span>title</span>;
const { result } = renderHook(() =>
useGetResolvedText({ text: node, maxLength: 2 }),
);
});
it('should stop at punctuation boundaries correctly', () => {
const text = 'Status: $service.name, Error: $error.type;';
const variables = {
'service.name': 'web-api',
'error.type': 'timeout',
};
const { result } = renderHookWithProps({ text }, variables);
expect(result.current.fullText).toBe('Status: web-api, Error: timeout;');
expect(result.current.truncatedText).toBe('Status: web-api, Error: timeout;');
expect(result.current.fullText).toBe(node);
expect(result.current.truncatedText).toBe(node);
});
});

View File

@@ -1,351 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { IDashboardVariablesStoreState } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
VariableFetchState,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { useIsPanelWaitingOnVariable } from '../useVariableFetchState';
function makeVariable(
overrides: Partial<IDashboardVariable> & { id: string },
): IDashboardVariable {
return {
name: overrides.id,
description: '',
type: 'QUERY',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
...overrides,
};
}
function resetStores(): void {
variableFetchStore.set(() => ({
states: {},
lastUpdated: {},
cycleIds: {},
}));
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
}));
}
function setFetchStates(states: Record<string, VariableFetchState>): void {
variableFetchStore.set(() => ({
states,
lastUpdated: {},
cycleIds: {},
}));
}
function setDashboardVariables(
overrides: Partial<IDashboardVariablesStoreState>,
): void {
dashboardVariablesStore.set(() => ({
dashboardId: '',
variables: {},
sortedVariablesArray: [],
dependencyData: null,
variableTypes: {},
dynamicVariableOrder: [],
...overrides,
}));
}
describe('useIsPanelWaitingOnVariable', () => {
beforeEach(() => {
resetStores();
});
it('should return false when variableNames is empty', () => {
const { result } = renderHook(() => useIsPanelWaitingOnVariable([]));
expect(result.current).toBe(false);
});
it('should return false when all referenced variables are idle', () => {
setFetchStates({ a: 'idle', b: 'idle' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'val1' }),
b: makeVariable({ id: 'b', selectedValue: 'val2' }),
},
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a', 'b']));
expect(result.current).toBe(false);
});
it('should return true when a variable is loading with empty selectedValue', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should return true when a variable is waiting with empty selectedValue', () => {
setFetchStates({ a: 'waiting' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: '' }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should return true when a variable is revalidating with empty selectedValue', () => {
setFetchStates({ a: 'revalidating' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should return false when a variable is loading but has a selectedValue', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'some-value' }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is loading but has a selectedValue', () => {
setFetchStates({ dyn: 'loading' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: 'some-val',
allSelected: true,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is waiting but has a selectedValue', () => {
setFetchStates({ dyn: 'waiting' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: 'val',
allSelected: true,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is idle', () => {
setFetchStates({ dyn: 'idle' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: 'val',
allSelected: true,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
});
it('should return false for non-DYNAMIC variable with allSelected=false and non-empty value that is loading', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({
id: 'a',
selectedValue: 'val',
allSelected: false,
}),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should return true if any one of multiple variables is blocking', () => {
setFetchStates({ a: 'idle', b: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'val' }),
b: makeVariable({ id: 'b', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY', b: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a', 'b']));
expect(result.current).toBe(true);
});
it('should return false when variable has no entry in fetch store (treated as idle)', () => {
setFetchStates({}); // no state entry for 'a'
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: 'val' }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should return false when variable is in error state with empty selectedValue', () => {
setFetchStates({ a: 'error' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should react to store updates', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: undefined }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
// Simulate variable fetch completing
act(() => {
variableFetchStore.update((d) => {
d.states.a = 'idle';
});
});
expect(result.current).toBe(false);
});
it('should handle DYNAMIC variable with allSelected=false and empty selectedValue as blocking', () => {
setFetchStates({ dyn: 'loading' });
setDashboardVariables({
variables: {
dyn: makeVariable({
id: 'dyn',
type: 'DYNAMIC',
selectedValue: undefined,
allSelected: false,
}),
},
variableTypes: { dyn: 'DYNAMIC' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(true);
});
it('should handle variable with array selectedValue as non-blocking when loading', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: ['val1', 'val2'] }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(false);
});
it('should handle variable with empty array selectedValue as blocking when loading', () => {
setFetchStates({ a: 'loading' });
setDashboardVariables({
variables: {
a: makeVariable({ id: 'a', selectedValue: [] }),
},
variableTypes: { a: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should find variable by name when store key differs from variable name', () => {
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: undefined,
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(true);
});
it('should respect selectedValue when store key differs from variable name', () => {
// When the variable has a value, it should not block even if loading
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: 'production',
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(false);
});
});

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -42,38 +41,10 @@ function useContextVariables({
// ! To be noted: This customVariables is not Dashboard Custom Variables
customVariables,
}: UseContextVariablesProps): UseContextVariablesResult {
const { dashboardVariables } = useDashboardVariables();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// Extract dashboard variables
const processedDashboardVariables = useMemo(() => {
return Object.entries(dashboardVariables)
.filter(([, value]) => value.name)
.map(([, value]) => {
let processedValue: string | number | boolean;
let isArray = false;
if (Array.isArray(value.selectedValue)) {
processedValue = value.selectedValue.join(', ');
isArray = true;
} else if (value.selectedValue != null) {
processedValue = value.selectedValue;
} else {
processedValue = '';
}
return {
name: value.name || '',
value: processedValue,
source: 'dashboard' as const,
isArray,
originalValue: value.selectedValue,
};
});
}, [dashboardVariables]);
// Extract global variables
const globalVariables = useMemo(
() => [
@@ -109,12 +80,8 @@ function useContextVariables({
// Combine all variables
const allVariables = useMemo(
() => [
...processedDashboardVariables,
...globalVariables,
...customVariablesList,
],
[processedDashboardVariables, globalVariables, customVariablesList],
() => [...globalVariables, ...customVariablesList],
[globalVariables, customVariablesList],
);
// Create processed variables with truncation logic

View File

@@ -1,40 +0,0 @@
import { useCallback, useRef, useSyncExternalStore } from 'react';
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
IDashboardVariablesStoreState,
IUseDashboardVariablesReturn,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
/**
* Generic selector hook for dashboard variables store
* Allows granular subscriptions to any part of the store state
*
* @example
* ! Select top-level field
* const variables = useDashboardVariablesSelector(s => s.variables);
*
* ! Select specific variable
* const fooVar = useDashboardVariablesSelector(s => s.variables['foo']);
*
* ! Select derived value
* const hasVariables = useDashboardVariablesSelector(s => Object.keys(s.variables).length > 0);
*/
export const useDashboardVariablesSelector = <T>(
selector: (state: IDashboardVariablesStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSnapshot = useCallback(
() => selectorRef.current(dashboardVariablesStore.getSnapshot()),
[],
);
return useSyncExternalStore(dashboardVariablesStore.subscribe, getSnapshot);
};
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
return { dashboardVariables };
};

View File

@@ -1,30 +0,0 @@
import { useMemo } from 'react';
import {
IDashboardVariable,
TVariableQueryType,
} from 'types/api/dashboard/variables';
import { useDashboardVariables } from './useDashboardVariables';
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType: 'values',
): IDashboardVariable[];
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType?: 'entries',
): [string, IDashboardVariable][];
export function useDashboardVariablesByType(
variableType: TVariableQueryType,
returnType?: 'values' | 'entries',
): IDashboardVariable[] | [string, IDashboardVariable][] {
const { dashboardVariables } = useDashboardVariables();
return useMemo(() => {
const entries = Object.entries(dashboardVariables || {}).filter(
(entry): entry is [string, IDashboardVariable] =>
Boolean(entry[1].name) && entry[1].type === variableType,
);
return returnType === 'values' ? entries.map(([, value]) => value) : entries;
}, [dashboardVariables, variableType, returnType]);
}

View File

@@ -0,0 +1,13 @@
import {
DynamicVariableSuggestion,
useDynamicVariableSuggestionsStore,
} from 'providers/Dashboard/store/dynamicVariableSuggestions';
/**
* Dynamic variables published by the dashboard currently open, so the query
* builder can offer `$variable` as a value for the key each one backs. Empty on
* surfaces with no dashboard behind them (APM, Celery, messaging queues).
*/
export function useDynamicVariableSuggestions(): DynamicVariableSuggestion[] {
return useDynamicVariableSuggestionsStore((state) => state.suggestions);
}

View File

@@ -1,18 +1,8 @@
// this hook is used to get the resolved text of a variable, lets say we have a text - "Logs count in $service.name in $severity and $service.name and $severity $service.name"
// and the values of service.name and severity are "service1" and "error" respectively, then the resolved text should be "Logs count in service1 in error and service1 and error service1"
// is case of the multiple variables value, make them comma separated
// also have a prop saying max length post that you should truncate the text with "..."
// return value should be a full text string, and a truncated text string (if max length is provided)
import { ReactNode, useCallback, useMemo } from 'react';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { ReactNode, useMemo } from 'react';
interface UseGetResolvedTextProps {
text: string | ReactNode;
variables?: Record<string, string | number | boolean>;
maxLength?: number;
matcher?: string;
maxValues?: number; // Maximum number of values to show before adding +n more
}
interface ResolvedTextResult {
@@ -20,173 +10,23 @@ interface ResolvedTextResult {
truncatedText: string | ReactNode;
}
/**
* Returns a panel title alongside a copy truncated to `maxLength`, so a card can
* show the short form and keep the full string for its tooltip. Non-string content
* passes through untouched.
*/
function useGetResolvedText({
text,
maxLength,
matcher = '$',
maxValues = 2, // Default to showing 2 values before +n more
}: UseGetResolvedTextProps): ResolvedTextResult {
const { dashboardVariables } = useDashboardVariables();
const isString = typeof text === 'string';
const processedDashboardVariables = useMemo(() => {
return Object.entries(dashboardVariables).reduce<
Record<string, string | number | boolean>
>((acc, [, value]) => {
if (!value.name) {
return acc;
}
// Handle array values
if (Array.isArray(value.selectedValue)) {
acc[value.name] = value.selectedValue.join(', ');
} else if (value.selectedValue != null) {
acc[value.name] = value.selectedValue;
}
return acc;
}, {});
}, [dashboardVariables]);
// Process array values to add +n more notation for truncated text
const processedVariables = useMemo(() => {
const result: Record<string, string> = {};
Object.entries(processedDashboardVariables).forEach(([key, value]) => {
// If the value contains array data (comma-separated string), format it with +n more
if (
typeof value === 'string' &&
!value.includes('-|-') &&
value.includes(',')
) {
const values = value.split(',').map((v) => v.trim());
if (values.length > maxValues) {
const visibleValues = values.slice(0, maxValues);
const remainingCount = values.length - maxValues;
result[key] = `${visibleValues.join(
', ',
)} +${remainingCount}-|-${values.join(', ')}`;
} else {
result[key] = `${values.join(', ')}-|-${values.join(', ')}`;
}
} else {
// For values already formatted with -|- or non-array values
result[key] = String(value);
}
});
return result;
}, [processedDashboardVariables, maxValues]);
const combinedPattern = useMemo(() => {
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const variablePatterns = [
`\\{\\{\\s*?\\.([^\\s}]+?)\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*([^\\s}]+?)\\s*\\}\\}`, // {{var}}
`${escapedMatcher}([^\\s.,;)\\]}>]+(?:\\.[^\\s.,;)\\]}>]+)*)`, // $var.name.path - allows dots but stops at punctuation
`\\[\\[\\s*([^\\s\\]]+?)\\s*\\]\\]`, // [[var]]
];
return new RegExp(variablePatterns.join('|'), 'g');
}, [matcher]);
const extractVarName = useCallback(
(match: string): string => {
// Extract variable name from different formats
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
if (match.startsWith('{{')) {
const dotMatch = match.match(
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
);
if (dotMatch) {
return dotMatch[1].trim();
}
const normalMatch = match.match(
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
);
if (normalMatch) {
return normalMatch[1].trim();
}
} else if (match.startsWith('[[')) {
const bracketMatch = match.match(
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
);
if (bracketMatch) {
return bracketMatch[1].trim();
}
} else if (match.startsWith(matcher)) {
// For $ variables, we always want to strip the prefix
// unless the full match exists in processedVariables
const withoutPrefix = match.substring(matcher.length).trim();
const fullMatch = match.trim();
// If the full match (with prefix) exists, use it
if (processedVariables[fullMatch] !== undefined) {
return fullMatch;
}
// Otherwise return without prefix
return withoutPrefix;
}
return match;
},
[matcher, processedVariables],
);
const fullText = useMemo(() => {
if (!isString) {
return text;
}
return (text as string)?.replace(combinedPattern, (match) => {
const varName = extractVarName(match);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts.length > 1 ? parts[1] : value;
}
return match;
});
}, [text, processedVariables, combinedPattern, extractVarName, isString]);
const truncatedText = useMemo(() => {
if (!isString) {
if (typeof text !== 'string' || !maxLength || text.length <= maxLength) {
return text;
}
return `${text.substring(0, maxLength - 3)}...`;
}, [text, maxLength]);
const result = (text as string)?.replace(combinedPattern, (match) => {
const varName = extractVarName(match);
const value = processedVariables[varName];
if (value != null) {
const parts = value.split('-|-');
return parts[0] || value;
}
return match;
});
if (maxLength && result.length > maxLength) {
// For the specific test case
if (maxLength === 20 && result.startsWith('Logs count in')) {
return 'Logs count in test, a...';
}
// General case
return `${result.substring(0, maxLength - 3)}...`;
}
return result;
}, [
text,
processedVariables,
combinedPattern,
maxLength,
extractVarName,
isString,
]);
return {
fullText,
truncatedText,
};
return { fullText: text, truncatedText };
}
export default useGetResolvedText;

View File

@@ -1,151 +0,0 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
import isEmpty from 'lodash-es/isEmpty';
import {
IVariableFetchStoreState,
VariableFetchState,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { useDashboardVariablesSelector } from './useDashboardVariables';
/**
* Generic selector hook for the variable fetch store.
* Same pattern as useDashboardVariablesSelector.
*/
const useVariableFetchSelector = <T>(
selector: (state: IVariableFetchStoreState) => T,
): T => {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSnapshot = useCallback(
() => selectorRef.current(variableFetchStore.getSnapshot()),
[],
);
return useSyncExternalStore(variableFetchStore.subscribe, getSnapshot);
};
interface UseVariableFetchStateReturn {
/** The current fetch state for this variable */
variableFetchState: VariableFetchState;
/** Current fetch cycle — include in react-query keys to auto-cancel stale requests */
variableFetchCycleId: number;
/** True if this variable is idle (not waiting and not fetching) */
isVariableSettled: boolean;
/** True if this variable is actively fetching (loading or revalidating) */
isVariableFetching: boolean;
/** True if this variable has completed at least one fetch cycle */
hasVariableFetchedOnce: boolean;
/** True if any parent variable hasn't settled yet */
isVariableWaitingForDependencies: boolean;
/** Message describing what this variable is waiting on, or null if not waiting */
variableDependencyWaitMessage?: string;
}
/**
* Per-variable hook that exposes the fetch state of a single variable.
* Reusable by both variable input components and panel components.
*
* Subscribes to both variableFetchStore (for states) and
* dashboardVariablesStore (for parent graph) to compute derived values.
*/
export function useVariableFetchState(
variableName: string,
): UseVariableFetchStateReturn {
// This variable's fetch state (loading, waiting, idle, etc.)
const variableFetchState = useVariableFetchSelector(
(s) => s.states[variableName] || 'idle',
) as VariableFetchState;
// All variable states — needed to check if parent variables are still in-flight
const allStates = useVariableFetchSelector((s) => s.states);
// Parent dependency graph — maps each variable to its direct parents
// e.g. { "childVariable": ["parentVariable"] } means "childVariable" depends on "parentVariable"
const parentGraph = useDashboardVariablesSelector(
(s) => s.dependencyData?.parentDependencyGraph,
);
// Timestamp of last successful fetch — 0 means never fetched
const lastUpdated = useVariableFetchSelector(
(s) => s.lastUpdated[variableName] || 0,
);
// Per-variable cycle counter — used as part of react-query keys
// so changing it auto-cancels stale requests for this variable only
const variableFetchCycleId = useVariableFetchSelector(
(s) => s.cycleIds[variableName] || 0,
);
const isVariableSettled = variableFetchState === 'idle';
const isVariableFetching =
variableFetchState === 'loading' || variableFetchState === 'revalidating';
// True after at least one successful fetch — used to show stale data while revalidating
const hasVariableFetchedOnce = lastUpdated > 0;
// Variable type — needed to differentiate waiting messages
const variableType = useDashboardVariablesSelector(
(s) => s.variableTypes[variableName],
);
// Parent variable names that haven't settled yet
const unsettledParents = useMemo(() => {
const parents = parentGraph?.[variableName] || [];
return parents.filter((p) => (allStates[p] || 'idle') !== 'idle');
}, [parentGraph, variableName, allStates]);
const isVariableWaitingForDependencies = unsettledParents.length > 0;
const variableDependencyWaitMessage = useMemo(() => {
if (variableFetchState !== 'waiting') {
return;
}
if (variableType === 'DYNAMIC') {
return 'Waiting for all query variable options to load.';
}
if (unsettledParents.length === 0) {
return;
}
const quoted = unsettledParents.map((p) => `"${p}"`);
const names =
quoted.length > 1
? `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`
: quoted[0];
return `Waiting for options of ${names} to load.`;
}, [variableFetchState, variableType, unsettledParents]);
return {
variableFetchState,
isVariableSettled,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
isVariableFetching,
hasVariableFetchedOnce,
variableFetchCycleId,
};
}
export function useIsPanelWaitingOnVariable(variableNames: string[]): boolean {
const states = useVariableFetchSelector((s) => s.states);
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
return variableNames.some((name) => {
const variableFetchState = states[name];
const variableData = Object.values(dashboardVariables).find(
(v) => v.name === name,
);
const { selectedValue } = variableData || {};
const isVariableInFetchingOrWaitingState =
variableFetchState === 'loading' ||
variableFetchState === 'revalidating' ||
variableFetchState === 'waiting';
return isEmpty(selectedValue) ? isVariableInFetchingOrWaitingState : false;
});
}

View File

@@ -32,12 +32,8 @@ jest.mock(
}),
);
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): unknown => ({ dashboardVariables: {} }),
}));
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown => ({}),
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
useDynamicVariableSuggestions: (): unknown[] => [],
}));
jest.mock('hooks/useNotifications', () => ({
@@ -46,10 +42,6 @@ jest.mock('hooks/useNotifications', () => ({
}),
}));
jest.mock('lib/dashboardVariables/getDashboardVariables', () => ({
getDashboardVariables: (): unknown => ({}),
}));
jest.mock('utils/getGraphType', () => ({
getGraphType: jest.fn().mockReturnValue('time_series'),
}));

View File

@@ -11,10 +11,8 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
@@ -38,11 +36,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const { notifications } = useNotifications();
const { dashboardVariables } = useDashboardVariables();
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
return useCallback(() => {
if (!widget) {
@@ -63,15 +57,16 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryType: widget.query.queryType,
});
}
const { queryPayload } = prepareQueryRangePayloadV5({
query: widget.query,
globalSelectedInterval,
graphType: getGraphType(widget.panelTypes),
selectedTime: widget.timePreferance,
variables: getDashboardVariables(dashboardVariables),
originalGraphType: widget.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});
const { queryPayload } = prepareQueryRangePayloadV5(
{
query: widget.query,
globalSelectedInterval,
graphType: getGraphType(widget.panelTypes),
selectedTime: widget.timePreferance,
originalGraphType: widget.panelTypes,
},
dashboardDynamicVariables,
);
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
@@ -107,7 +102,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
globalSelectedInterval,
notifications,
queryRangeMutation,
dashboardVariables,
dashboardDynamicVariables,
widget,
]);

View File

@@ -5,7 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { updateBarStepInterval } from 'container/WidgetCard/utils';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import {
GetMetricQueryRange,
GetQueryResultsProps,
@@ -33,10 +33,7 @@ export const useGetQueryRange: UseGetQueryRange = (
options,
headers,
) => {
const dashboardDynamicVariables = useDashboardVariablesByType(
'DYNAMIC',
'values',
);
const dashboardDynamicVariables = useDynamicVariableSuggestions();
const newRequestData: GetQueryResultsProps = useMemo(() => {
const firstQueryData = requestData.query.builder?.queryData[0];

View File

@@ -17,8 +17,8 @@ import {
import { Pagination } from 'hooks/queryPagination';
import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
import { isEmpty } from 'lodash-es';
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/variables';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
@@ -179,7 +179,7 @@ export const getLegend = (
export async function GetMetricQueryRange(
props: GetQueryResultsProps,
version: string,
dynamicVariables?: IDashboardVariable[],
dynamicVariables: DynamicVariableSuggestion[] = [],
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<MetricQueryRangeSuccessResponse> {
@@ -226,10 +226,7 @@ export async function GetMetricQueryRange(
}
if (version === ENTITY_VERSION_V5) {
const v5Result = prepareQueryRangePayloadV5({
...props,
dynamicVariables,
});
const v5Result = prepareQueryRangePayloadV5(props, dynamicVariables);
legendMap = v5Result.legendMap;
// atleast one query should be there to make call to v5 api
@@ -364,5 +361,4 @@ export interface GetQueryResultsProps {
end?: number;
step?: number;
originalGraphType?: PANEL_TYPES;
dynamicVariables?: IDashboardVariable[];
}

View File

@@ -1,239 +0,0 @@
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/variables';
/**
* Inter-variable dependency graph over the shared dashboard-variables store. A
* QUERY variable "depends on" another when its query text references that
* variable, so changing a value must refetch its dependents.
*
* Keyed on `IDashboardVariable`. The V2 editor has a parallel implementation
* over its own flat form model in
* `pages/DashboardPage/DashboardContainer/VariablesBar/utils/variableDependencies.ts`.
*/
export type VariableGraph = Record<string, string[]>;
/** Names of QUERY variables whose query references `variableName`. */
const getDependentVariablesBasedOnVariableName = (
variableName: string,
variables: IDashboardVariable[],
): string[] => {
if (!variables || !Array.isArray(variables)) {
return [];
}
return variables
.map((variable) => {
if (variable.type === 'QUERY') {
const queryValue = variable.queryValue || '';
if (textContainsVariableReference(queryValue, variableName)) {
return variable.name;
}
}
return null;
})
.filter((val): val is string => val !== null);
};
/** variable name → its direct dependents (children). */
export const buildDependencies = (
variables: IDashboardVariable[],
): VariableGraph => {
const graph: VariableGraph = {};
// Initialize empty arrays for all variables first
variables.forEach((variable) => {
if (variable.name) {
graph[variable.name] = [];
}
});
// For each QUERY variable, add it as a dependent to its referenced variables
variables.forEach((variable) => {
if (variable.name) {
const dependentVariables = getDependentVariablesBasedOnVariableName(
variable.name,
variables,
);
// For each referenced variable, add the current query as a dependent
graph[variable.name] = dependentVariables;
}
});
return graph;
};
/** Invert a child graph into a parent graph. */
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {
const parentGraph: VariableGraph = {};
// Initialize empty arrays for all nodes
Object.keys(graph).forEach((node) => {
parentGraph[node] = [];
});
// For each node and its children in the original graph
Object.entries(graph).forEach(([node, children]) => {
// For each child, add the current node as its parent
children.forEach((child) => {
if (!parentGraph[child]) {
parentGraph[child] = [];
}
parentGraph[child].push(node);
});
});
return parentGraph;
};
const collectCyclePath = (
graph: VariableGraph,
start: string,
end: string,
): string[] => {
const path: string[] = [];
let current = start;
const findParent = (node: string): string | undefined =>
Object.keys(graph).find((key) => graph[key]?.includes(node));
while (current !== end) {
const parent = findParent(current);
if (!parent) {
break;
}
path.push(parent);
current = parent;
}
return [start, ...path];
};
const detectCycle = (
graph: VariableGraph,
node: string,
visited: Set<string>,
recStack: Set<string>,
): string[] | null => {
if (!visited.has(node)) {
visited.add(node);
recStack.add(node);
const neighbors = graph[node] || [];
let cycleNodes: string[] | null = null;
neighbors.some((neighbor) => {
if (!visited.has(neighbor)) {
const foundCycle = detectCycle(graph, neighbor, visited, recStack);
if (foundCycle) {
cycleNodes = foundCycle;
return true;
}
} else if (recStack.has(neighbor)) {
// Found a cycle, collect the cycle nodes
cycleNodes = collectCyclePath(graph, node, neighbor);
return true;
}
return false;
});
if (cycleNodes) {
return cycleNodes;
}
}
recStack.delete(node);
return null;
};
/** Topological order, parent graph, transitive descendants and cycle info. */
export const buildDependencyGraph = (
dependencies: VariableGraph,
// eslint-disable-next-line sonarjs/cognitive-complexity
): IDependencyData => {
const inDegree: Record<string, number> = {};
const adjList: VariableGraph = {};
// Initialize in-degree and adjacency list
Object.keys(dependencies).forEach((node) => {
if (!inDegree[node]) {
inDegree[node] = 0;
}
if (!adjList[node]) {
adjList[node] = [];
}
dependencies[node]?.forEach((child) => {
if (!inDegree[child]) {
inDegree[child] = 0;
}
inDegree[child]++;
adjList[node].push(child);
});
});
// Detect cycles
const visited = new Set<string>();
const recStack = new Set<string>();
let cycleNodes: string[] | undefined;
Object.keys(dependencies).some((node) => {
if (!visited.has(node)) {
const foundCycle = detectCycle(dependencies, node, visited, recStack);
if (foundCycle) {
cycleNodes = foundCycle;
return true;
}
}
return false;
});
// Topological sort using Kahn's Algorithm
const queue: string[] = Object.keys(inDegree).filter(
(node) => inDegree[node] === 0,
);
const topologicalOrder: string[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) {
break;
}
topologicalOrder.push(current);
adjList[current]?.forEach((neighbor) => {
inDegree[neighbor]--;
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
});
}
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
// Pre-compute transitive descendants by walking topological order in reverse.
// Each node's transitive descendants = direct children + their transitive descendants.
const transitiveDescendants: VariableGraph = {};
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
const node = topologicalOrder[i];
const desc = new Set<string>();
for (const child of adjList[node] || []) {
desc.add(child);
for (const d of transitiveDescendants[child] || []) {
desc.add(d);
}
}
transitiveDescendants[node] = Array.from(desc);
}
return {
order: topologicalOrder,
graph: adjList,
parentDependencyGraph: buildParentDependencyGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
};

View File

@@ -1,41 +0,0 @@
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import store from 'store';
export const getDashboardVariables = (
variables?: IDashboardVariables,
): Record<string, unknown> => {
if (!variables) {
return {};
}
try {
const { globalTime } = store.getState();
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: globalTime.selectedTime,
});
const variablesTuple: Record<string, unknown> = {
SIGNOZ_START_TIME: parseInt(start, 10) * 1e3,
SIGNOZ_END_TIME: parseInt(end, 10) * 1e3,
};
Object.entries(variables).forEach(([, value]) => {
if (value?.name) {
variablesTuple[value.name] =
value?.type === 'DYNAMIC' &&
value?.allSelected &&
value?.showALLOption &&
value?.multiSelect
? '__all__'
: value?.selectedValue;
}
});
return variablesTuple;
} catch (e) {
console.error(e);
}
return {};
};

View File

@@ -1,5 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
@@ -7,11 +6,6 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
import { buildYAxisSizeCalculator } from '../utils/axis';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
@@ -67,12 +61,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { panelType } = this.props;
const { isTimeAxis } = this.props;
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}

View File

@@ -1,5 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import type uPlot from 'uplot';
@@ -137,11 +136,11 @@ describe('UPlotAxisBuilder', () => {
});
});
it('uses time-based X-axis values formatter for time-series like panels', () => {
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
isTimeAxis: true,
}),
);
@@ -150,11 +149,11 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBe(uPlotXAxisValuesFormat);
});
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
it('does not attach X-axis datetime formatter for a non-time axis', () => {
const builder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
isTimeAxis: false,
}),
);
@@ -290,22 +289,9 @@ describe('UPlotAxisBuilder', () => {
expect(config.space).toBe(50);
});
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
const barBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.BAR,
}),
);
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
const timeSeriesBuilder = new UPlotAxisBuilder(
createAxisProps({
scaleKey: 'x',
panelType: PANEL_TYPES.TIME_SERIES,
}),
);
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
it('omits the X-axis datetime formatter when no time axis is declared', () => {
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
expect(builder.getConfig().values).toBeUndefined();
});
it('should return the existing size when cycleNum > 1', () => {

View File

@@ -1,5 +1,4 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
@@ -53,31 +52,51 @@ export interface ConfigBuilderProps {
* Props for configuring an axis
*/
export interface AxisProps {
/** Scale this axis is drawn against — `'x'` / `'y'`, matching an `addScale` key. Also
* selects the default tick formatter and sizing (x: time, y: value + unit). */
scaleKey: string;
/** Axis title drawn alongside the ticks; omitted when there's nothing to name. */
label?: string;
/** Render the axis at all; false keeps the scale but draws no ticks or labels. */
show?: boolean;
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
/** Which edge of the plot the axis sits on: 0 | 1 | 2 | 3 — top, right, bottom, left. */
side?: 0 | 1 | 2 | 3;
/** Tick/label color. Defaults to black or white from `isDarkMode`. */
stroke?: string;
/** Partial override of the grid lines; unset keys fall back to the theme defaults. */
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
/** Partial override of the tick marks; provided as-is to uPlot when set. */
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
/** Minimum pixels between ticks, capping how many uPlot draws. For log scale axes. */
space?: number;
/** Picks the dark or light default for stroke and grid color. */
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
yAxisUnit?: string;
panelType?: PANEL_TYPES;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
* (histogram) leaves it off.
*/
isTimeAxis?: boolean;
/** Decimal places for y axis tick values; unset lets the unit formatter decide. */
decimalPrecision?: PrecisionOption;
}

View File

@@ -14,6 +14,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { PanelMode } from 'lib/visualization/panels/types';
import { plotsTimeOnXAxis } from 'lib/visualization/panels/utils/panelAxis';
export interface BaseConfigBuilderProps {
id: string;
@@ -124,7 +125,7 @@ export function buildBaseConfig({
side: 2,
isDarkMode,
isLogScale,
panelType,
isTimeAxis: plotsTimeOnXAxis(panelType),
});
builder.addAxis({
@@ -134,7 +135,6 @@ export function buildBaseConfig({
isDarkMode,
isLogScale,
yAxisUnit,
panelType,
});
return builder;

View File

@@ -0,0 +1,9 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/**
* Whether the panel type plots time on X. Graph and bar do; the rest drawn through
* `buildBaseConfig` — histogram buckets, billing categories — plot a value there instead.
*/
export function plotsTimeOnXAxis(panelType: PANEL_TYPES): boolean {
return panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR;
}

View File

@@ -5,10 +5,11 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
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';
@@ -67,7 +68,7 @@ function ConfigPane({
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
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.

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

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

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

@@ -26,3 +26,16 @@
background: var(--l2-border);
}
}
// The static editor's preview: the panel card the grid shows, minus actions.
.staticPreviewSurface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}

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

@@ -13,7 +13,6 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -22,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 { PANEL_KIND_TO_PANEL_TYPE } 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. */
@@ -56,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelKind,
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
@@ -64,8 +58,12 @@ function PanelEditorQueryBuilder({
footer,
stickyHeader = true,
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// 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[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 = panelDefinition.kind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -96,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]: {
@@ -112,9 +110,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={panelType !== PANEL_TYPES.LIST}
showTraceOperator={!isListViewPanel}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
isListViewPanel={isListViewPanel}
queryComponents={{}}
signalSourceChangeEnabled
savePreviousQuery
@@ -148,7 +146,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode]);
}, [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()}
@@ -60,6 +63,7 @@ function renderBuilder(
function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
showTraceOperator: boolean;
filterConfigs: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
@@ -115,6 +119,9 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('graph');
expect(props.isListViewPanel).toBe(false);
// The trace operator combines aggregated trace queries, so it rides along with
// the aggregation controls.
expect(props.showTraceOperator).toBe(true);
expect(props.filterConfigs).toStrictEqual({});
});
@@ -124,6 +131,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
const props = lastQueryBuilderProps();
expect(props.panelType).toBe('list');
expect(props.isListViewPanel).toBe(true);
expect(props.showTraceOperator).toBe(false);
expect(props.filterConfigs).toStrictEqual({
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },

View File

@@ -1,26 +1,14 @@
import { Spline } from '@signozhq/icons';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QueryTypeTag from 'components/QueryTypeTag/QueryTypeTag';
import { EQueryType } from 'types/common/dashboard';
interface PlotTagProps {
/** Authoring mode of the panel's query; undefined when no query exists yet. */
queryType: EQueryType | undefined;
panelType: PANEL_TYPES;
className?: string;
}
/**
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
* query exists, where the mode is irrelevant.
*/
function PlotTag({
queryType,
panelType,
className,
}: PlotTagProps): JSX.Element | null {
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
function PlotTag({ queryType, className }: PlotTagProps): JSX.Element | null {
if (queryType === undefined) {
return null;
}

View File

@@ -6,8 +6,7 @@ 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 type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { RenderableQueryPanelDefinition } 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 type {
@@ -21,8 +20,8 @@ import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
/** 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;
@@ -72,7 +71,6 @@ function PreviewPane({
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
// Search term is ephemeral preview state, threaded to header + renderer but
@@ -84,11 +82,7 @@ function PreviewPane({
<div className={styles.preview}>
{!hideHeader && (
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<PlotTag queryType={queryType} className={styles.queryType} />
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
@@ -113,7 +107,7 @@ function PreviewPane({
hideActions
/>
<PanelBody
panelDefinition={panelDefinition}
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={data}

View File

@@ -1,30 +1,17 @@
import { render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import PlotTag from '../PlotTag';
describe('PlotTag', () => {
it('renders the resolved query mode', () => {
render(
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
);
render(<PlotTag queryType={EQueryType.PROM} />);
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
it('renders nothing when there is no query yet', () => {
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
it('renders nothing for list panels (query mode is irrelevant)', () => {
render(
<PlotTag
queryType={EQueryType.QUERY_BUILDER}
panelType={PANEL_TYPES.LIST}
/>,
);
render(<PlotTag queryType={undefined} />);
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,331 @@
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 { PANEL_KIND_TO_PANEL_TYPE } 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 { 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;
/** The dashboard can be edited (unlocked + permission); gates Save. */
isEditable: boolean;
/** Why Save is disabled (locked / no permission); '' when editable. */
editDisabledReason: string;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
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,
isEditable,
editDisabledReason,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: QueryEditorBodyProps): JSX.Element {
// 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: PANEL_KIND_TO_PANEL_TYPE[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}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
}
preview={
<PreviewPane
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,135 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
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,
isEditable,
editDisabledReason,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticEditorBodyProps): JSX.Element {
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 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}
readOnlyReason={editDisabledReason}
onSave={onSave}
onClose={onCloseEditor}
/>
}
preview={
<div className={styles.staticPreviewSurface}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
panelDefinition={panelDefinition}
panel={draft}
panelId={panelId}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
</div>
}
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,6 +3,7 @@ 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';
@@ -232,7 +233,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 +262,7 @@ describe('PanelEditorContainer composition', () => {
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(

View File

@@ -18,6 +18,8 @@ import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
@@ -83,7 +85,7 @@ function EditorRoute(): JSX.Element {
return (
<PanelEditorQueryBuilder
panelKind="signoz/TimeSeriesPanel"
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}

View File

@@ -19,6 +19,10 @@ jest.mock('lib/query/panelQuery', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isQuerylessPanelKind: jest.requireActual('../../../Panels/capabilities')
.isQuerylessPanelKind,
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),

View File

@@ -1,36 +1,27 @@
import { useMemo } from 'react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
type LegendSeries,
resolvePieLegendSeries,
resolveTimeSeriesLegendSeries,
} from '../utils/legendSeries';
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
* legend-colors control can key overrides by the exact labels the chart draws. Only the
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
* Series from its flat series); every other kind returns none.
* legend-colors control can key overrides by the exact labels the chart draws, using
* the resolver the kind declares as its `colors` control.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): LegendSeries[] {
const isDarkMode = useIsDarkMode();
const kind = panel.spec.plugin.kind;
return useMemo(() => {
switch (panel.spec.plugin.kind) {
case 'signoz/PieChartPanel':
return resolvePieLegendSeries(data, isDarkMode);
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/HistogramPanel':
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
default:
return [];
}
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
return resolve
? resolve({ queries: panel.spec.queries, data, isDarkMode })
: [];
}, [kind, panel.spec.queries, data, isDarkMode]);
}

View File

@@ -4,21 +4,19 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
@@ -35,6 +33,12 @@ interface UsePanelEditSessionArgs {
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
/**
* Externally-owned draft. The editor shell hoists it above its mode fork so a
* kind switch across modes survives the branch swap; hosts without a fork (the
* View modal, until it forks) omit it and the session owns the draft.
*/
draftApi?: PanelEditorDraftApi;
}
export interface UsePanelEditSessionReturn {
@@ -47,7 +51,7 @@ export interface UsePanelEditSessionReturn {
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderablePanelDefinition;
panelDefinition: RenderableQueryPanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
@@ -59,8 +63,6 @@ export interface UsePanelEditSessionReturn {
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -77,22 +79,26 @@ export function usePanelEditSession({
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
draftApi,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
// Called unconditionally (hooks rules); unused when a hoisted draft is passed in.
const internalDraftApi = usePanelEditorDraft(panel, savedPanel);
const { draft, spec, setSpec, isSpecDirty, reset } =
draftApi ?? internalDraftApi;
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Hosts fork on `definition.mode` before mounting this session (the editor and
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({
panel: draft,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
time,
enabled: !!panelDefinition,
enabled: isPanelKindSupported(panelKind),
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
@@ -105,12 +111,6 @@ export function usePanelEditSession({
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
@@ -124,6 +124,5 @@ export function usePanelEditSession({
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -6,7 +6,7 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
@@ -18,7 +18,10 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import {
isQuerylessPanelKind,
resolveQueryType,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -128,11 +131,25 @@ export function usePanelTypeSwitch({
queries,
});
// Revisit → restore the stash verbatim (the reversibility path).
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
redirectWithQueryBuilderData(cached.builderQuery);
if (!isQuerylessPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isQuerylessPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
return;
}
@@ -146,7 +163,7 @@ export function usePanelTypeSwitch({
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
newPanelType === PANEL_TYPES.LIST
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]

View File

@@ -1,56 +1,13 @@
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
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 { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } 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 { 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 layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
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 ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import styles from './PanelEditor.module.scss';
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 PanelEditorContainerProps {
export interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -74,293 +31,42 @@ interface PanelEditorContainerProps {
}
/**
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
* V2 panel editor page shell. Owns exactly the state that must survive a switch
* between authoring modes — the draft and the kind-switch cache — and forks on
* the draft kind's `mode`: query kinds get the session-backed body, static kinds
* an editor pane over a live preview with no query machinery at all.
*/
function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
isEditable,
editDisabledReason,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// 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,
panelDefinition,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { panel, savedPanel } = props;
const draftApi = usePanelEditorDraft(panel, savedPanel);
// 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 { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// 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,
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
setSpec: draftApi.setSpec,
});
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]);
if (panelDefinition.mode === 'static') {
return (
<StaticEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
}
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<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 minSize="55%" maxSize="65%" defaultSize="60%">
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<QueryEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
}

View File

@@ -1,9 +1,18 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
@@ -15,6 +24,7 @@ import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
@@ -37,9 +47,118 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/ListPanel': [logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
'signoz/TimeSeriesPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Bar bins client-side, so it asks for a widened step interval over a raw series.
'signoz/BarChartPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/HistogramPanel': {
requestType: time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/NumberPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/PieChartPanel': {
requestType: scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only Table asks the server to transpose its scalar result into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
};
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
});
// A dashboard spec written by a newer SigNoz can name a kind this build has no
// definition for. The registry answers with UNSUPPORTED_PANEL rather than nothing, so
// every guard below reads it without first proving a definition exists.
describe('a kind this build cannot render', () => {
const unknownKind = 'signoz/SomeFutureKindPanel' as PanelKind;
it('is not reported as supported', () => {
expect(isPanelKindSupported(unknownKind)).toBe(false);
expect(isPanelKindSupported('signoz/TimeSeriesPanel')).toBe(true);
});
it('still resolves to a definition', () => {
expect(getPanelDefinition(unknownKind)).toBe(UNSUPPORTED_PANEL);
});
it('declares nothing, so it is never offered as authorable', () => {
expect(getSupportedSignals(unknownKind)).toStrictEqual([]);
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
expect(isSignalSupported(unknownKind, logs)).toBe(false);
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
});
it('offers no actions', () => {
expect(getPanelDefinition(unknownKind).actions).toStrictEqual(
NO_PANEL_ACTIONS,
);
expect(NO_PANEL_ACTIONS.view).toBe(false);
expect(NO_PANEL_ACTIONS.edit).toBe(false);
expect(NO_PANEL_ACTIONS.drilldown).toBe(false);
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const queryCapabilities = requireQueryPanelDefinition(unknownKind)
.queryCapabilities;
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);
});
});
describe('query type support', () => {
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
expect(getSupportedQueryTypes(kind)).toStrictEqual(

View File

@@ -2,7 +2,11 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import {
mergeQueryBuilderFieldRule,
type FilterConfigsPartial,
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
/**
@@ -13,11 +17,46 @@ import type { PanelKind } from './types/panelKind';
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Renders from its own plugin spec — no query surface at all. */
export function isQuerylessPanelKind(kind: PanelKind): boolean {
return getPanelDefinition(kind).mode === 'static';
}
/**
* The kind's definition narrowed to the query arm, or null for a static kind.
* The null is what hosts fork on; the accessors below fold it into "supports
* nothing" for the guard questions.
*/
export function getQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition | null {
const definition = getPanelDefinition(kind);
return definition.mode === 'query' ? definition : null;
}
/**
* The query arm, asserted present. For call sites that a host mounts only after
* narrowing `mode === 'query'` but that read the definition by kind rather than
* receiving it as a prop — the throw makes that invariant executable instead of
* silently null-tolerant.
*/
export function requireQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition {
const definition = getQueryPanelDefinition(kind);
if (!definition) {
throw new Error(
`query machinery mounted for query-less panel kind ${kind} — the host must fork on definition.mode before this point`,
);
}
return definition;
}
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getPanelDefinition(kind).supportedSignals;
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
}
export function isSignalSupported(
@@ -29,7 +68,7 @@ export function isSignalSupported(
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getPanelDefinition(kind).supportedQueryTypes;
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
}
export function isQueryTypeSupportedByPanelKind(
@@ -53,6 +92,10 @@ export function isPanelCombinationValid({
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isQuerylessPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return false;
}
@@ -73,7 +116,11 @@ export function resolveQueryType(
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
return supported.includes(preferred) ? preferred : supported[0];
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
}
/**
@@ -85,7 +132,6 @@ export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getPanelDefinition(kind).queryBuilderFields;
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
return mergeQueryBuilderFieldRule(rule, signal);
}

View File

@@ -20,8 +20,12 @@ interface NoDataProps {
isFetching?: boolean;
/** When provided, renders a Retry button that re-runs the query. */
onRetry?: () => void;
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
panel?: DashboardtypesPanelDTO;
/**
* The panel this empty state stands in for. Every renderer has it, and it decides
* whether the global "Extend time range" action applies (a panel locked to a fixed
* time preference can't be widened by it) as well as what the action events report.
*/
panel: DashboardtypesPanelDTO;
'data-testid'?: string;
}
@@ -43,19 +47,17 @@ function NoData({
const globalExtend = useExtendTimeWindow();
// The View modal's local extender wins; the global one only applies to a panel that
// follows the ambient window (a fixed preference can't be widened by it).
const hasFixedTimePreference = panel
? panelHasFixedTimePreference(panel)
: false;
const activeExtend =
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
viewExtend ?? (panelHasFixedTimePreference(panel) ? undefined : globalExtend);
if (isFetching) {
return <PanelLoader />;
}
const panelType = panel
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
: undefined;
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
const panelKind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel
@@ -65,6 +67,7 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'extendTime',
panelType,
panelKind,
});
activeExtend.extend();
},
@@ -79,6 +82,7 @@ function NoData({
void logEvent(DashboardDetailEvents.NoDataAction, {
action: 'retry',
panelType,
panelKind,
});
onRetry();
},

View File

@@ -33,7 +33,12 @@ function panelWith(
timePreference?: DashboardtypesTimePreferenceDTO,
): DashboardtypesPanelDTO {
return {
spec: { plugin: { spec: { visualization: { timePreference } } } },
spec: {
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { visualization: { timePreference } },
},
},
} as unknown as DashboardtypesPanelDTO;
}
@@ -44,7 +49,7 @@ describe('NoData', () => {
});
it('renders the empty-state title and hint', () => {
render(<NoData />);
render(<NoData panel={panelWith()} />);
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
@@ -55,7 +60,7 @@ describe('NoData', () => {
it('offers to extend the window as the primary action', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData />);
render(<NoData panel={panelWith()} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Extend time range');
@@ -68,7 +73,7 @@ describe('NoData', () => {
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
const onRetry = jest.fn();
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData onRetry={onRetry} />);
render(<NoData onRetry={onRetry} panel={panelWith()} />);
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
'Extend time range',
@@ -82,7 +87,7 @@ describe('NoData', () => {
it('falls back to Retry as the sole action when the window cannot be widened', () => {
const onRetry = jest.fn();
render(<NoData onRetry={onRetry} />);
render(<NoData onRetry={onRetry} panel={panelWith()} />);
const action = screen.getByTestId('panel-no-data-action');
expect(action).toHaveTextContent('Retry');
@@ -101,7 +106,7 @@ describe('NoData', () => {
useViewPanelStore.setState({
viewPanelExtendWindow: extender({ extend: storeExtend }),
});
render(<NoData />);
render(<NoData panel={panelWith()} />);
fireEvent.click(screen.getByTestId('panel-no-data-action'));
expect(storeExtend).toHaveBeenCalledTimes(1);
@@ -109,7 +114,7 @@ describe('NoData', () => {
});
it('renders no action when nothing can be widened and no retry handler', () => {
render(<NoData />);
render(<NoData panel={panelWith()} />);
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
expect(
@@ -119,7 +124,7 @@ describe('NoData', () => {
it('shows the panel loader (not the empty state) while refetching', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData isFetching />);
render(<NoData isFetching panel={panelWith()} />);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
@@ -128,7 +133,7 @@ describe('NoData', () => {
it('honours the data-testid override for the number panel', () => {
mockUseExtendTimeWindow.mockReturnValue(extender());
render(<NoData data-testid="number-panel-no-data" />);
render(<NoData data-testid="number-panel-no-data" panel={panelWith()} />);
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
});

View File

@@ -1,13 +1,22 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
@@ -20,6 +29,15 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,3 +1,4 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
@@ -13,7 +14,10 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },

View File

@@ -1,33 +1,22 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
buildBaseConfig,
type TimeAxisChromeArgs,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
export interface BuildBarChartConfigArgs extends TimeAxisChromeArgs {
spec: DashboardtypesBarChartPanelSpecDTO;
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */
@@ -47,7 +36,7 @@ export function buildBarChartConfig({
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isTimeAxis: true,
isDarkMode,
timezone,
panelMode,

View File

@@ -1,13 +1,22 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
@@ -20,6 +29,15 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,3 +1,4 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { SectionKind, type SectionConfig } from '../../types/sections';
@@ -9,7 +10,7 @@ export const sections: SectionConfig[] = [
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: true },
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
Boolean(

View File

@@ -1,8 +1,8 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import {
buildBaseConfig,
type PanelChromeArgs,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import getLabelName from 'lib/getLabelName';
@@ -16,16 +16,12 @@ const BAR_WIDTH_FACTOR = 1;
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
export interface BuildHistogramConfigArgs extends PanelChromeArgs {
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
@@ -44,7 +40,7 @@ export function buildHistogramConfig({
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isTimeAxis: false,
isDarkMode,
timezone,
panelMode,

View File

@@ -0,0 +1,34 @@
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
import ListColumnsEditor from '../../../PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
/**
* List's editor pane: the query builder with the columns editor pinned below it.
* Declared here so no editor host carries a List special case.
*/
function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
spec,
onChangeSpec,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
footer={
<ListColumnsEditor spec={spec} onChangeSpec={onChangeSpec} signal={signal} />
}
/>
);
}
export default ListEditorPane;

View File

@@ -1,14 +1,23 @@
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
mode: 'query',
icon: List,
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,
@@ -30,6 +39,15 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
},
},
sections,
// The only kind reading raw rows: they page server-side, and the sort needs a
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.raw,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: true,
serverPaginated: true,
},
actions: {
view: true,
edit: true,

View File

@@ -1,13 +1,22 @@
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
mode: 'query',
icon: Hash,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
@@ -20,6 +29,13 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,13 +1,22 @@
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
mode: 'query',
icon: ChartPie,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
@@ -16,6 +25,13 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: false,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,

View File

@@ -1,3 +1,4 @@
import { resolvePieLegendSeries } from '../../utils/legendSeries';
import { SectionKind, type SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
@@ -8,6 +9,9 @@ export const sections: SectionConfig[] = [
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolvePieLegendSeries },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,13 +1,22 @@
import { Table } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
mode: 'query',
icon: Table,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
@@ -16,6 +25,14 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {
view: true,

View File

@@ -0,0 +1,25 @@
// Tripled on purpose: the markdown reset (`.content.content *`) weighs (0,2,0),
// and a doubled class only ties it — leaving the winner to stylesheet order,
// which reverted `position: relative` and let the copy button anchor to the
// panel instead of the block. (0,3,0) wins regardless of order.
.codeBlock.codeBlock.codeBlock {
position: relative;
}
// The wrapper is a reset-exempt island (see MarkdownContent.module.scss), so
// plain classes style it; the button inside keeps its design-system look.
.copyButton {
position: absolute;
top: 4px;
right: 4px;
border-radius: 3px;
background: var(--l3-background);
opacity: 0;
transition: opacity 0.15s ease;
}
// GitHub-style reveal: hover anywhere on the block, or keyboard focus.
.codeBlock.codeBlock.codeBlock:hover .copyButton,
.codeBlock.codeBlock.codeBlock:focus-within .copyButton {
opacity: 1;
}

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