Compare commits

..

27 Commits

Author SHA1 Message Date
Ashwin Bhatkal
f87829c4dd chore(dashboard-v2): ban direct patchDashboardV2 via oxlint
Add a no-restricted-imports rule forbidding patchDashboardV2 / usePatchDashboardV2
from api/generated/services/dashboard, directing callers to useOptimisticPatch().
patchAsync so every spec edit goes through the optimistic cache path. The hook
itself (the one sanctioned caller) and its test carry a scoped inline exception.
2026-07-02 08:54:16 +05:30
Ashwin Bhatkal
23ee1c3b0a feat(dashboard-v2): route settings/toolbar edits through optimistic patch
Migrate the remaining patchDashboardV2 spec edits — variable-definition save,
Overview metadata (title/description/image/tags), and toolbar rename — onto
useOptimisticPatch. The toolbar keeps its refetch prop for the lock/unlock toggle
(a non-patch API), and the edit-context refetch stays for the JSON editor's
full-document save; both are outside this PR's patch-op scope.
2026-07-02 08:31:16 +05:30
Ashwin Bhatkal
99d2851313 feat(dashboard-v2): route panel editor save through optimistic patch
Migrate usePanelEditorSave off usePatchDashboardV2 + invalidateQueries onto the
central useOptimisticPatch (passing the editor's explicit dashboardId). The isNew
branch still reads cached layouts to resolve the target section.
2026-07-02 08:31:16 +05:30
Ashwin Bhatkal
e9730f9ec7 feat(dashboard-v2): route panel add/move/delete through optimistic patch
Migrate the grid-item panel mutations (delete, move-between-sections, clone) onto
useOptimisticPatch so they render instantly and reconcile on settle. useClonePanel
keeps its toast.promise UX over the patch promise. Update the clone test to assert
against the ops passed to patchAsync.
2026-07-02 08:31:16 +05:30
Ashwin Bhatkal
2af49efc45 feat(dashboard-v2): route section/layout edits through optimistic patch
Migrate the section and layout mutations (rename, add, delete, reorder, resize/
move persist, first-section migration) off the 'await patchDashboardV2(...);
refetch()' pattern onto useOptimisticPatch, so section edits render instantly and
reconcile in the background. The explicit refetch is dropped (onSettled invalidates)
and each hook keeps its own toast/error handling.
2026-07-02 08:31:16 +05:30
Ashwin Bhatkal
3c4e357836 feat(dashboard-v2): add useOptimisticPatch central mutation hook
A single react-query mutation over patchDashboardV2 that applies the ops to the
cached dashboard on onMutate (via applyJsonPatch, patching the envelope's .data),
snapshots for rollback on onError, and invalidates on settle to reconcile. Reads
dashboardId from the edit-context store (with an optional override for the panel
editor, which receives its id as a prop) and exposes error; rethrows so call sites
keep their own error handling.
2026-07-02 08:31:16 +05:30
Ashwin Bhatkal
420e087b27 feat(dashboard-v2): add lenient RFC-6902 JSON-Patch applier
Add applyJsonPatch, a pure applier for the add/replace/remove ops our patchOps
builders emit. Deep-clones and returns a new document (never mutates input), and
is deliberately lenient like the backend apply (remove/replace on a missing path
is a no-op, add creates missing object parents). This lets a dashboard edit be
reflected in the react-query cache optimistically, with the mutation's settle
refetch as the reconcile safety net.
2026-07-02 08:31:16 +05:30
Abhi kumar
fea3be7c51 feat(dashboards-v2): panel editor — threshold carry, span-gaps fixes, metric unit defaults, live thresholds and pie multi-column fix (#11918)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat(dashboards-v2): carry thresholds across a panel visualization-type switch

When switching a panel's visualization kind mid-edit, thresholds now carry
over if the target kind supports them, remapped to the target's variant
(label/comparison/table) — keeping the shared core (color, value, unit) and
seeding any variant-required fields (operator, format, column) with sensible
defaults so the carried threshold stays functional. Kinds without a Thresholds
section drop them. The carry is a first-visit seed; reversible round-trips
still restore from the per-kind session cache.

* fix(dashboards-v2): span-gaps Disconnect Values reactivity + fillOnlyBelow flag

Fixes three issues in the chart-appearance span-gaps control and makes the
fillOnlyBelow flag authoritative end-to-end:

- Threshold default now seeds from the live query step interval (which arrives
  async) instead of being captured once at mount, so it no longer falls back
  to 1m.
- The threshold input no longer re-commits an unchanged value on blur, so
  clicking "Never" reliably switches mode (the blur/toggle race is gone).
- Invalid input is validated live as the user types, surfacing the error
  immediately rather than only on blur.
- Selecting Threshold writes fillOnlyBelow: true (+ duration); Never writes
  fillOnlyBelow: false and drops the duration. The selected mode is derived
  from fillOnlyBelow, and the renderer honors it (explicit false spans every
  gap), with back-compat for panels saved before the flag existed.

* feat(dashboards-v2): auto-seed panel unit from the metric with a mismatch warning

V1 parity for the formatting unit selector: when the selected metric carries a
unit, a new panel auto-initializes its unit from it, and choosing a different
unit shows the "Unit mismatch" warning. Reuses the shared useGetYAxisUnit hook
and YAxisUnitSelector read-only.

The resolution + auto-seed runs at the editor level (not inside the collapsible
FormattingSection) so it applies even while that section is closed; the
resolved metric unit is threaded down via context purely to drive the warning.
Seeding is gated to new panels — editing never overwrites a saved unit.

* feat(dashboards-v2): reactive threshold editing with live preview

Threshold edits now stream to the spec as the user types, so the panel preview
reflects them before Save. The per-row draft is mirrored into the spec via an
onLiveChange effect in useThresholdDraft; because edits reach the spec live,
ThresholdsSection snapshots the saved value on edit entry and restores it on
Discard. Save keeps the value, Discard rolls back, and add-then-discard still
removes the row.

* fix(dashboard): apply pie multi-column scalar fix to v2 panels

Mirror the V1 pie fix in the V2 PieChartPanel. preparePieData already reads
the scalar table (via prepareScalarTables) but used
columns.find(isValueColumn), so only the first value column was plotted — a
ClickHouse `count() AS col1, sum() AS col2` collapsed to a single slice.

It now emits one slice per (row × value column); with multiple value columns
the column name distinguishes the slices (prefixed by the group when
grouped). Single-value and grouped panels are unchanged — a single value
column iterates exactly once.

Per the V1/V2 split, this duplicates the behaviour into V2 land rather than
sharing the V1 helper.

* chore: pr review fixes
2026-07-02 00:45:26 +00:00
Srikanth Chekuri
66f03d5912 fix(metrics): use local table for fingerprint ctes (#11931)
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
Release Drafter / update_release_draft (push) Has been cancelled
* fix(metrics): use local table for fingerprint ctes

* chore: remove queries file

* chore: update reduced_test.go
2026-07-01 16:14:11 +00:00
Pandey
cf69a05f74 fix(dashboards): expose Source as a string enum in the OpenAPI schema (#11930)
The reflector saw Source's unexported valuer.String field and emitted
type: object. Add a JSONSchema exposer that pins type: string, deriving
the enum values from the existing Enum() method so the list of sources
lives in exactly one place.
2026-07-01 15:46:24 +00:00
Vikrant Gupta
1648fce5b1 chore(authz): delete the deprecated authz apis (#11924)
* chore(authz): delete the deprecated authz apis

* test(authz): rework role integration tests onto the new CRUD APIs

Migrate the role integration suite off the deprecated PATCH endpoints and
onto the current declarative role CRUD APIs (Create/Get/List/Update/Delete
with full transactionGroups).

- role/01_register.py: verify managed roles via GetRole's transactionGroups
  against a golden matrix in testdata/role/managed_role_grants.json (no more
  DB tuple assertions).
- role/02_crud.py (new): custom-role CRUD lifecycle, declarative update,
  validation (naming, invalid verb/type/kind/selector, duplicate, managed
  immutability, delete-with-assignee), and license gating.
- role/03_fga.py: resource FGA allow/deny via declarative grant sets.
- role/02_user.py: deleted; user role-membership is covered by the
  passwordauthn suite.
- serviceaccount/06_fga.py: migrated to declarative grant PUTs.
- fixtures/role.py: pure data helpers + find_role_id fixture; tests make
  their HTTP calls directly.

* test(authz): scope role/SA FGA tests to fine-grained selectors

- role FGA: grant read/update/delete on a specific role name (not "*") and
  assert allowed-on-granted vs forbidden-on-other; create is collection-scoped;
  list on "*" returns every role.
- serviceaccount FGA: grant on a specific SA id (with a second SA to prove
  cross-instance denial); dual attach/detach scoped to SA id + role name.
- add create_role fixture (alongside find_role_id) for happy-path role creation;
  validation/failure cases stay inline.
- underscore-prefix file-local constants in both FGA modules.

* test(authz): rename grants terminology to transactions in role tests
2026-07-01 13:11:55 +00:00
Abhi kumar
f93a70884a feat(dashboards-v2): create alerts from a dashboard panel (#11879)
* feat(dashboards-v2): create alerts from a dashboard panel

Wire the panel actions menu's "Create Alerts" item to seed a new alert
from the panel's query. `buildCreateAlertUrl` translates the panel's V5
queries into the V1 compositeQuery the alert page reads (tagged with the
panel type, v5 version and a dashboards source), and
`useCreateAlertFromPanel` opens /alerts/new in a new tab and logs the
action. Available regardless of edit access (V1 parity: create-alert
works on locked dashboards too).

To reach the query, the full panel is threaded through
Panel -> PanelHeader -> PanelActionsMenu -> usePanelActionItems instead
of just its kind; the header now derives its name/description from the
panel as well.

* feat(dashboards-v2): add a Create alert rule action to the panel editor

Add an "Actions" group at the foot of the editor config pane — a list of
cross-page navigation links kept distinct from the collapsible config
sections above. The first action, "Create alert", reuses
useCreateAlertFromPanel to seed an alert from the draft query; the group
hides for kinds that can't seed an alert and scales to more actions.

ConfigPane now derives the panel kind from its spec and takes the draft
panel + panelId so the group can build the link.

* refactor(dashboards-v2): address review feedback on panel create-alert

- buildCreateAlertUrl: drop redundant `?? []` (spec.queries is a required array)
- buildCreateAlertUrl: extract FormattablePluginSpec type for the formatting cast
- usePanelActionItems: rename kindActions -> panelCapabilities (its type is
  PanelActionCapabilities; avoids the clash with the panelActions prop)
- useCreateAlerts (V1): mark @deprecated pointing at the V2 create-alert path

* chore: pr review changes
2026-07-01 11:31:15 +00:00
Srikanth Chekuri
e1c586e0dc chore(metrics): review follow ups for volume control (#11887)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore(metrics): review follow ups for volume control

* chore: the reduced metrics show up in summary page

* chore: 1h; 6h window changes

* chore: address some gaps

* chore: asset warning gap

* chore: address lint

* chore: regenerate api
2026-07-01 11:04:41 +00:00
Vikrant Gupta
984b2d0138 fix(authz): drop the organization grant tuples (#11922)
* fix(authz): stop seeding organization role tuples and remove existing ones

Fetching the signoz-admin role via GET /api/v1/roles/{id} panicked with
"invalid input format: organization:organization/*". Organization objects
use a 2-part id (organization:organization/<selector>) that
MustNewObjectFromString cannot parse, unlike the 4-part format every other
resource emits.

The signoz-admin managed role granted read/update on the organization
resource, so those tuples were read back and crashed the parser. These
grants are dead weight: org routes are gated by admin role membership, not
by the organization object.

Remove the organization transactions from the signoz-admin seed and add
migration 098 to delete existing organization tuples and changelog rows.

* fix(authz): parse organization object string in MustNewObjectFromString

The organization resource is the root entity and encodes its object as
"organization:organization/<selector>", without the orgID and kind
segments every other resource uses. The 4-part parser panicked with
"invalid input format" when it encountered this shape.

Detect the type first, then handle the organization 2-part format
explicitly, mirroring resourceOrganization.Object(). All other resources
keep the existing 4-part path. This makes listing a role's permissions
robust to organization tuples regardless of how they were created.
2026-07-01 09:26:53 +00:00
Ashwin Bhatkal
3ea62d3d50 feat(dashboard-v2): link variables to panels and substitute them into panel queries (#11909)
* feat(dashboard-v2): build V5 variables payload from selection

Add buildVariablesPayload, a pure builder mapping a dashboard's variable
definitions + runtime selection into the V5 query-range `variables` map
({ name: { type, value } }). Mirrors V1 getDashboardVariables: maps the
QUERY/CUSTOM/TEXT/DYNAMIC UI types to wire types, collapses a multi-select
dynamic ALL to the __all__ sentinel, falls back to configured defaults, and
omits empties. buildQueryRangeRequest now accepts a `variables` arg (defaults
to {}) instead of hardcoding an empty map.

* feat(dashboard-v2): add resolvedVariables store channel

Add a transient (non-persisted) resolvedVariables map to the variable-selection
slice, keyed by dashboardId, with a setResolvedVariables setter and a
selectResolvedVariables selector. This is the published-to-store channel the
panel query reads from, mirroring the edit-context publish pattern so the
dashboard spec is not threaded down the panel tree.

* feat(dashboard-v2): substitute variable selection into panel queries

Add useResolvedVariables, which derives the variable definitions from the spec,
reads the runtime selection from the store, builds the V5 payload, and publishes
it via setResolvedVariables. DashboardContainer calls it once. usePanelQuery
reads selectResolvedVariables(dashboardId) and threads it into the request and
the query key, so each panel (and the editor preview) substitutes the bar's
selected values and refetches when a selection changes.
2026-07-01 07:12:14 +00:00
Gaurav Tewari
9317a26337 feat(llm-pricing): search + source filter (4/5) (#11808)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* feat(llm-pricing): add model cost drawer and wire into listing page

* fix(llm-pricing): restrict pricing management to admins

Align the frontend write gate with the backend, which protects the
LLM pricing create/update/delete endpoints with AdminAccess (admin
only). Previously manage_llm_pricing allowed EDITOR/AUTHOR, so those
roles saw the Add/Save affordances but their writes were rejected with
a 403. Also removes the AUTHOR entry, which could never reach the page
(the route gate excludes it).

* fix(llm-pricing): read-only drawer shows View title, hides source picker

Non-managers open the drawer in view mode (write APIs are Admin-only), so:
- the heading reads "View model cost" instead of "Edit model cost"
- the Source (auto vs. override) picker is hidden, since switching source is
  a manager-only action with nothing actionable for a viewer.

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* feat: add search , dropdown and flag

* feat: feature flag on entire route and add mode costs tabs

* fix: add isFetchingFeatureFlags

* chore: update flag

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* empty commit

* chore: empty commit

* fix: add demo side nav on sidenav

* chore: remove demo side nav

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* refactor(llm-pricing): break model-cost drawer into per-component files + tokens

Apply the CSS-module/component conventions to the drawer that came from
drawer-3:
- Move the drawer under ModelCostTabPanel/components/ModelCostDrawer/ to mirror
  the ModelCostsTable structure
- Split the single 395-LOC ModelCostDrawer.module.scss into per-component
  co-located modules; cross-component selectors live in shared.module.scss and
  are pulled in via CSS-modules `composes`
- shared.module.scss is a composes target (parsed as plain CSS), so it is kept
  flat with block comments — no SCSS nesting or // comments
- Use --text-vanilla-* (not --bg-vanilla-*) for text colors, matching the
  listing code

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* refactor: css variables

* refactor: use signoz button and minor css update

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* refactor: side nav changes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: add enable check

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-01 06:31:20 +00:00
Gaurav Tewari
fde817d83c feat(llm-pricing): model cost add/edit drawer (3/5) (#11761)
* feat(llm-pricing): add model pricing foundation (route, permission, page shell)

* feat(llm-pricing): add listing page and table

* chore(llm-pricing): drop search + source filters from list request

The list API does not honour the q (search) and source params yet, so
the controls did nothing. Remove the search input and source dropdown
along with the params we sent, and trim useModelPricingFilters to the
URL-backed page state that pagination still needs. Currency dropdown,
tabs, table and pagination are unchanged. Filters will return once the
backend supports them.

* refactor(llm-pricing): extract getRelativeTime helper in utils

Pull the relative-time formatting out of getRelativeLastSeen into a
small local getRelativeTime helper. Kept feature-local (not in the
shared utils/timeUtils) so the LLM pricing module owns its own dayjs
config; the local relativeTime extend stays for test self-sufficiency.

* refactor(llm-pricing): drop dead NaN guard in formatPricePerMillion

Pricing fields are typed as required numbers and JSON can't carry NaN,
so Number.isNaN was unreachable. Keep the null/undefined guard as API
defensiveness (toFixed on a missing value would crash the row). Also
trims the now-redundant dayjs.extend comment.

* refactor(llm-pricing): centralize constants and shared types

Extract PAGE_SIZE, PAGE_KEY, COLUMN_COUNT and CURRENCY_OPTIONS into a
new constants.ts, and move the ModelPricingFilters contract into
types.ts. Component prop interfaces stay colocated with their
components, matching the convention in the drawer PR.

* refactor(llm-pricing): use nuqs for list pagination URL state

Replace the hand-rolled useHistory + URLSearchParams plumbing in
useModelPricingFilters with nuqs useQueryState, matching the convention
used by the dashboards, alerts and k8s list pages. Behaviour is
unchanged: parseAsInteger.withDefault(1) keeps ?page=1 out of the URL
and history:'replace' avoids polluting the back-stack.

* refactor(llm-pricing): inline pagination, drop useModelPricingFilters

The hook had shrunk to a one-line nuqs wrapper after search/source were
removed, so inline the useQueryState call into the container and remove
the hook file plus the now-unused ModelPricingFilters type. When the
filters return (once the API honours them) they can move back into a
dedicated hook.

* feat(llm-pricing): disable currency selector (USD-only for now)

Only USD is priced today, so render the currency SelectSimple in a
disabled state pinned to USD. A disabled select can't fire onChange, so
the currency useState is dead — drop it (and the now-unused useState
import).

* refactor(llm-pricing): render model costs inside its tab + tab URL param

The listing was rendered outside the Tabs, so the tab was decorative.
Move all model-cost content (currency control, list query, table,
pagination, footer) into a ModelCostsTab component rendered as the
'Model costs' tab's children, and drive the active tab from a 'tab' URL
query param (nuqs). The container is now just the page shell. Unpriced
models stays a disabled placeholder for a later PR.

* style(llm-pricing): target @signozhq table slots, drop dead antd/leftover rules

The component uses @signozhq/ui Table/Tabs (Radix-based), not antd, so the
.ant-table-* and .ant-tabs-nav selectors never matched — the intended
uppercase/muted header styling wasn't applied. Retarget header/cell rules to
[data-slot='table-head'|'table-cell'] (no !important needed). Also remove dead
rules left over from the removed search/source/add UI (.filters-bar__search,
__source, __add, .page-header__actions) and the unused .source-badge--auto/
--override modifiers.

* fix(llm-pricing): constrain currency dropdown width, drop tab URL param

- Currency SelectSimple stretched to fill the filters bar; give it a fixed
  160px width (min-width couldn't cap the trigger).
- Model costs is the only enabled tab for now, so use Tabs defaultValue
  instead of a URL-backed param. Removes the nuqs tab state plus the now-unused
  TAB_KEYS/TAB_QUERY_KEY constants and TabKey type.

* chore: self review changes

* fix: add skeleton loading

* refactor: self review changes

* refactor: initial prop

* fix: update styling

* fix: add comments in utils

* feat(llm-pricing): add model cost drawer and wire into listing page

* fix(llm-pricing): restrict pricing management to admins

Align the frontend write gate with the backend, which protects the
LLM pricing create/update/delete endpoints with AdminAccess (admin
only). Previously manage_llm_pricing allowed EDITOR/AUTHOR, so those
roles saw the Add/Save affordances but their writes were rejected with
a 403. Also removes the AUTHOR entry, which could never reach the page
(the route gate excludes it).

* fix(llm-pricing): read-only drawer shows View title, hides source picker

Non-managers open the drawer in view mode (write APIs are Admin-only), so:
- the heading reads "View model cost" instead of "Edit model cost"
- the Source (auto vs. override) picker is hidden, since switching source is
  a manager-only action with nothing actionable for a viewer.

* refactor: form in edit / add modal

* chore: update color tokens

* fix: add error handling

* chore: update more self review changes

* chore: self review changes

* chore: self review changes

* fix: minor grammer thing

* fix: route thing

* refactor: migrate to css moduel

* refactor: migrate to css module

* refactor: migrate to css module

* refactor: migrate to tanstack table

* docs: clarify price precision comment

* chore: remove comment

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

* refactor(llm-pricing): break model-cost drawer into per-component files + tokens

Apply the CSS-module/component conventions to the drawer that came from
drawer-3:
- Move the drawer under ModelCostTabPanel/components/ModelCostDrawer/ to mirror
  the ModelCostsTable structure
- Split the single 395-LOC ModelCostDrawer.module.scss into per-component
  co-located modules; cross-component selectors live in shared.module.scss and
  are pulled in via CSS-modules `composes`
- shared.module.scss is a composes target (parsed as plain CSS), so it is kept
  flat with block comments — no SCSS nesting or // comments
- Use --text-vanilla-* (not --bg-vanilla-*) for text colors, matching the
  listing code

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-01 05:42:46 +00:00
Abhi kumar
13812fac62 fix(dashboard): pie panel collapses multi-column ClickHouse query to a single slice (#11919)
* fix(dashboard): pie panel collapses multi-column clickhouse scalar to one slice

A pie panel backed by a ClickHouse query with several aggregations
(e.g. `count() AS col1, sum() AS col2`) rendered a single slice labelled
with the query name and only the first value column's value; the other
value columns were silently dropped.

Root cause: the scalar response carries every value column in the scalar
table, but PiePanelWrapper read the legacy `data.result` time-series field
instead. For a scalar that field collapses to a single series that keeps
only the first value column, so the pie never saw the rest. This is the
pie counterpart of the table collapse fixed in #11794.

Fix: when the scalar table has more than one value column, build pie
slices from the scalar table under `newResult` (the same source the table
and value panels already use) — one slice per value column, group-by
columns become the label. Single-aggregation and grouped pies keep the
existing series path unchanged. Frontend-only, V1.

* fix: formatter datetime

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-07-01 05:15:47 +00:00
Vinicius Lourenço
df77b8d125 fix(settings): ensure scroll on tiny screens (#11916)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-30 18:45:47 +00:00
Swapnil Nakade
028ac27496 feat: adding cloud integration API changes for GCP (#11892)
* feat: adding cloud integration API changes for GCP

* chore: generating openapi specs

* fix: integration tests

* ci: fixing golang ci lint
2026-06-30 17:13:53 +00:00
Vinicius Lourenço
8b56f39261 refactor(getting-started): use new invite members component (#11874) 2026-06-30 15:09:54 +00:00
Tushar Vats
2be1063602 feat: QB support for QueryRangePreview (#11185)
* fix: added dry run api

* fix: set nullable for api response fields

* fix: missed adding one file

* fix: comment lint

* fix: finding 1

* fix: moved methods to telemetrystore

* fix: move interface to telemetrystore

* fix: remove wrong flow of imports

* fix: generate openapi

* fix: move explain methods to clickhousetelemetrystore
2026-06-30 14:43:45 +00:00
Vinicius Lourenço
6546602242 refactor(members-page): use new invite members component (#11873)
* refactor(members-page): use new invite members component

* chore(invite-members): move to be under member settings
2026-06-30 14:32:40 +00:00
Vinicius Lourenço
ba49b28c5a refactor(onboarding-questionaire): use new invite members component (#11875) 2026-06-30 14:32:03 +00:00
Vinicius Lourenço
adc3909b71 feat(sso-configuration): change roles selector to allow custom roles (#11894) 2026-06-30 14:31:58 +00:00
Pandey
e00f47c812 feat(web): add sentry, posthog, appcues and pylon settings to web config (#11912)
* feat(web): move sentry dsn and tunnel to runtime web settings

Move the Sentry dsn and tunnel out of build-time Vite injection into the
web.settings config so they are configurable per deployment at runtime via
SIGNOZ_WEB_SETTINGS_SENTRY_DSN and SIGNOZ_WEB_SETTINGS_SENTRY_TUNNEL. The
backend injects them into index.html and the frontend reads them from
window.signozBootData.settings; environment and release stay build-time.

* feat(web): move posthog key, api_host and ui_host to runtime web settings

Move the PostHog project key, api_host and ui_host out of build-time Vite
injection into the web.settings config so they are configurable per
deployment at runtime via SIGNOZ_WEB_SETTINGS_POSTHOG_KEY,
SIGNOZ_WEB_SETTINGS_POSTHOG_API__HOST and SIGNOZ_WEB_SETTINGS_POSTHOG_UI__HOST.
The backend injects them into index.html and the frontend reads them from
window.signozBootData.settings; api_host falls back to
https://us.i.posthog.com when unset.

* feat(web): move appcues app id to runtime web settings

Move the Appcues app id out of build-time Vite injection into the
web.settings config so it is configurable per deployment at runtime via
SIGNOZ_WEB_SETTINGS_APPCUES_APP__ID. The backend injects it into index.html
and the Appcues loader reads it from window.signozBootData.settings.

* feat(web): move pylon app id and identity secret to runtime web settings

Move the Pylon app id and identity secret out of build-time Vite injection
into the web.settings config so they are configurable per deployment at
runtime via SIGNOZ_WEB_SETTINGS_PYLON_APP__ID and
SIGNOZ_WEB_SETTINGS_PYLON_IDENTITY__SECRET. The backend injects them into
index.html and the frontend reads them from window.signozBootData.settings.
This was the last build-time integration value, so the now-unused
createHtmlPlugin is removed.

* chore(web): remove unused TUNNEL_DOMAIN

VITE_TUNNEL_DOMAIN / process.env.TUNNEL_DOMAIN was only referenced by
frontend/src/setupProxy.js, a dead Create-React-App artifact that Vite
never loads. Remove the vite define, the type declaration, the dead
setupProxy.js file, and the CI steps that wrote VITE_TUNNEL_DOMAIN to .env.

* chore(ci): drop build-time VITE_ vars now served at runtime

Sentry dsn/tunnel, posthog key, appcues app id, and pylon app id /
identity secret are now configured at runtime via SIGNOZ_WEB_SETTINGS_*
and no longer baked into the bundle, so the CI steps writing them to
.env at build time are dead. Keep the build-only Sentry sourcemap vars
(auth token, org, project id), VITE_VERSION, VITE_ENVIRONMENT and
VITE_DOCS_BASE_URL.

* chore(web): revert frontend and CI web settings changes

Drop the frontend consumption (AppRoutes, vite config, index.html, env
typings, bootSettings helper, setupProxy) and the CI workflow edits for
the web settings migration; these will be done separately. The backend
web.settings config and the generated schema/types stay.

* refactor(web): make new web settings keys optional

The new web settings keys (sentry dsn/tunnel, posthog key/api_host/
ui_host, appcues app_id, pylon app_id/identity_secret) are not required;
drop required:"true" so they are optional in the generated schema and
types. Only enabled stays required.
2026-06-30 13:26:24 +00:00
Ashwin Bhatkal
b2f048770e feat(dashboards-v2): list tags, DSL search, per-user pinning & org-shared views (#11868)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(dashboards-v2): remove temporary V2 list banner

The 'You're on the V2 dashboards page' warning banner was a stop-gap while the
page was in flux. Remove it now that the page is the intended destination.

* feat(dashboards-v2): strict key:value tag input with inline edit

Add a shared TagKeyValueInput that accepts tags only as key:value pairs,
committed on Enter and rejecting bare values, with double-click inline editing,
removable sienna chips, and a key:value parser. Use it on the dashboard settings
Overview form and the create-dashboard modal (replacing the comma-separated text
field). The dashboard list item type adopts the per-user list DTO so rows carry
the data the tag/pin features need.

* feat(dashboards-v2): DSL search with key suggestions and tag filtering

The search box now passes raw filter DSL straight through (parenthesized to keep
its precedence isolated) instead of wrapping the term in name CONTAINS, so users
can type name/tag/created_by clauses directly. A suggestions dropdown offers the
reserved DSL keys plus the tag keys reported by the list endpoint. Adds a Tags
filter chip (multi-select from the API's tags) alongside Created by / Updated.

* feat(dashboards-v2): per-user dashboard pinning

Replace the localStorage favorite star with backend per-user pinning. The row
action pins/unpins via the v2 per-user endpoints (pin/unpin), refreshes the
personalized list, and surfaces the 10-pin limit (HTTP 409) as a toast. Uses the
Pin/PinOff icons.

* feat(dashboards-v2): org-shared saved views via the views API

Persist saved views through the backend Views API (list/create/update/delete)
instead of localStorage, so they are shared across the org. A view stores the
combined DSL query plus sort/order and folds into the search box on select. Wires
the personalized list (pinned-aware), renames the 'Favorites' built-in to
'Pinned', renames the metadata popover to 'Columns', and polishes the views rail
(active accent, left-aligned title).

* refactor(dashboards-v2): group list utils under a utils/ folder

Move filterQuery, dslSuggestions, views and the general helpers (plus their tests)
into DashboardsListPageV2/utils/ so the page's growing set of non-component modules
lives in one place; update import paths. No behavior change.

* feat(dashboards-v2): retain tags across refetches + DSL keyboard nav

Accumulate the tags reported by the list endpoint across refetches so previously-
seen tags stay selectable in the Tags chip and DSL suggestions even when a filtered
page omits them. The DSL suggestions dropdown is now keyboard-navigable (arrow keys
+ Enter, Escape to close) and renders as a seamless full-width panel attached to the
input.

* refactor(dashboards-v2): prefer @signozhq Button/Typography, polish views rail

Replace raw <button>/<span> in the V2 list and tag input with @signozhq Button and
Typography, and apply icon classes directly to the icon (no wrapper span). Switch the
pin row to Pin/PinOff. Views rail: larger view-name font, 'Filter views by name'
placeholder, and an editable tag chip using a flattened ghost Button.

* chore(dashboards-v2): fix formatting in moved util files

* refactor(dashboards-v2): use an enum for built-in view ids

Replace the BuiltinViewId string-literal union with a TS enum (string values
unchanged, so the URL view param stays compatible) and use its members across the
view catalogue, snapshots, and the All-view check.

* refactor(dashboards-v2): trim redundant tag-chip remove-button overrides

Ghost background is already transparent and the hover colors are not needed; keep
only the size overrides plus color:inherit so the close icon matches the chip sienna.

* fix(dashboards-v2): don't open the dashboard when cancelling delete

The confirm dialog renders through this component's contextHolder, which sits inside
the row's click-to-open handler; React replays portal events up the tree, so the
Cancel click bubbled to the row and navigated. Stop propagation on cancel.

* fix(dashboards-v2): blend the views-rail delete action into the row

Revert the delete action to a ghost icon that stays transparent over the row's hover
background and only turns red on its own hover (the outlined variant broke the
unified background), stop its click from selecting the view, and restore the
content-driven row height so all rows stay uniform.

* fix(dashboards-v2): overlay the views-rail delete instead of reserving space

Absolutely position the delete action on the row's right edge (created views only)
so it no longer takes flex space, and drop the --button-height:auto hack so the row
keeps its natural height. pointer-events are gated so the hidden button can't
intercept row clicks.

* fix(dashboards-v2): align tag chip weight and fix remove-button hover

Match the chip text weight to the list-row badge (normal, not medium) so the colour
reads the same, and give the remove (×) a sienna-tinted hover instead of the Button's
default grey. Resting × colour stays at the Button default.
2026-06-30 10:35:45 +00:00
330 changed files with 13282 additions and 8455 deletions

View File

@@ -177,9 +177,11 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return nil, err
}
azureCloudProviderModule := implcloudprovider.NewAzureCloudProvider(defStore)
gcpCloudProviderModule := implcloudprovider.NewGCPCloudProvider(defStore)
cloudProvidersMap := map[cloudintegrationtypes.CloudProviderType]cloudintegration.CloudProviderModule{
cloudintegrationtypes.CloudProviderTypeAWS: awsCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeAzure: azureCloudProviderModule,
cloudintegrationtypes.CloudProviderTypeGCP: gcpCloudProviderModule,
}
return implcloudintegration.NewModule(pkgcloudintegration.NewStore(sqlStore), dashboardModule, global, zeus, gateway, licensing, serviceAccount, cloudProvidersMap, config)

View File

@@ -65,15 +65,31 @@ web:
posthog:
# Whether to enable PostHog in web.
enabled: false
# The PostHog project API key.
key: ""
# The PostHog API host. Defaults to https://us.i.posthog.com when empty.
api_host: ""
# The PostHog UI host. Used when api_host points at a reverse proxy.
ui_host: ""
appcues:
# Whether to enable Appcues in web.
enabled: false
# The Appcues account/app ID.
app_id: ""
sentry:
# Whether to enable Sentry in web.
enabled: false
# The Sentry DSN.
dsn: ""
# The Sentry tunnel URL.
tunnel: ""
pylon:
# Whether to enable Pylon in web.
enabled: false
# The Pylon app ID.
app_id: ""
# The Pylon identity verification secret.
identity_secret: ""
##################### Cache #####################
cache:

View File

@@ -618,13 +618,6 @@ components:
provider:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableRole:
properties:
description:
type: string
required:
- description
type: object
AuthtypesPostableAuthDomain:
properties:
config:
@@ -1024,6 +1017,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesAgentReport:
nullable: true
@@ -1169,6 +1164,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPConnectionArtifact'
type: object
CloudintegrationtypesCredentials:
properties:
@@ -1199,6 +1196,46 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesGCPConnectionArtifact:
type: object
CloudintegrationtypesGCPIntegrationConfig:
type: object
CloudintegrationtypesGCPServiceConfig:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceLogsConfig'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceMetricsConfig'
type: object
CloudintegrationtypesGCPServiceLogsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGCPServiceMetricsConfig:
properties:
enabled:
type: boolean
required:
- enabled
type: object
CloudintegrationtypesGettableAccountWithConnectionArtifact:
properties:
connectionArtifact:
@@ -1331,6 +1368,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPAccountConfig'
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -1355,6 +1394,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
type: object
CloudintegrationtypesService:
properties:
@@ -1399,6 +1440,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPServiceConfig'
type: object
CloudintegrationtypesServiceDashboard:
properties:
@@ -1441,6 +1484,7 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1502,6 +1546,8 @@ components:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
gcp:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableGCPAccountConfig'
type: object
CloudintegrationtypesUpdatableAzureAccountConfig:
properties:
@@ -1512,6 +1558,22 @@ components:
required:
- resourceGroups
type: object
CloudintegrationtypesUpdatableGCPAccountConfig:
properties:
deploymentProjectId:
type: string
deploymentRegion:
type: string
projectIds:
items:
type: string
nullable: true
type: array
required:
- deploymentProjectId
- deploymentRegion
- projectIds
type: object
CloudintegrationtypesUpdatableService:
properties:
config:
@@ -2467,22 +2529,6 @@ components:
- resource
- selectors
type: object
CoretypesPatchableObjects:
properties:
additions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
deletions:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
nullable: true
type: array
required:
- additions
- deletions
type: object
CoretypesResourceRef:
properties:
kind:
@@ -2626,6 +2672,7 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -2667,6 +2714,14 @@ components:
type: string
dashboardName:
type: string
filterBy:
items:
type: string
type: array
groupBy:
items:
type: string
type: array
panelId:
type: string
panelName:
@@ -3516,7 +3571,7 @@ components:
- user
- system
- integration
type: object
type: string
DashboardtypesSpanGaps:
properties:
fillLessThan:
@@ -3567,6 +3622,7 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3603,6 +3659,7 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -5340,6 +5397,9 @@ components:
type: string
id:
type: string
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
@@ -5352,9 +5412,9 @@ components:
$ref: '#/components/schemas/MetricreductionruletypesMatchType'
metricName:
type: string
reductionPercent:
format: double
type: number
retainedSamples:
minimum: 0
type: integer
retainedSeries:
minimum: 0
type: integer
@@ -5372,7 +5432,8 @@ components:
- active
- ingestedSeries
- retainedSeries
- reductionPercent
- ingestedSamples
- retainedSamples
type: object
MetricreductionruletypesGettableReductionRulePreview:
properties:
@@ -5415,15 +5476,23 @@ components:
estimatedMonthlySavingsUsd:
format: double
type: number
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
retainedSamples:
minimum: 0
type: integer
retainedSeries:
minimum: 0
type: integer
required:
- ingestedSeries
- retainedSeries
- ingestedSamples
- retainedSamples
- estimatedMonthlySavingsUsd
type: object
MetricreductionruletypesGettableReductionRules:
@@ -5489,7 +5558,6 @@ components:
- metric
- ingested_volume
- reduced_volume
- reduction
- last_updated
type: string
MetricreductionruletypesUpdatableReductionRule:
@@ -6209,6 +6277,25 @@ components:
- asc
- desc
type: string
Querybuildertypesv5PreviewStatement:
properties:
db.statement.args:
items: {}
type: array
db.statement.query:
type: string
estimate:
items:
$ref: '#/components/schemas/TelemetrystoretypesEstimateEntry'
type: array
granules:
$ref: '#/components/schemas/TelemetrystoretypesGranules'
required:
- db.statement.query
- db.statement.args
- estimate
- granules
type: object
Querybuildertypesv5PromQuery:
properties:
disabled:
@@ -6529,6 +6616,40 @@ components:
required:
- type
type: object
Querybuildertypesv5QueryPreview:
properties:
error: {}
statements:
items:
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
type: array
valid:
type: boolean
warnings:
items:
type: string
type: array
required:
- valid
- error
- warnings
- statements
type: object
Querybuildertypesv5QueryRangePreviewResponse:
description: Response from the v5 query range preview (dry-run) endpoint. For
each query in the composite query, returns the underlying ClickHouse statement(s)
it renders to without executing them (one per PromQL metric selector; exactly
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
ESTIMATE and granule analysis attached per statement when requested.
properties:
compositeQuery:
additionalProperties:
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
nullable: true
type: object
required:
- compositeQuery
type: object
Querybuildertypesv5QueryRangeRequest:
description: Request body for the v5 query range endpoint. Supports builder
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
@@ -7879,6 +8000,96 @@ components:
- key
- value
type: object
TelemetrystoretypesEstimateEntry:
properties:
database:
type: string
marks:
format: int64
type: integer
parts:
format: int64
type: integer
rows:
format: int64
type: integer
table:
type: string
required:
- database
- table
- parts
- rows
- marks
type: object
TelemetrystoretypesGranules:
nullable: true
properties:
initial:
format: int64
type: integer
reads:
items:
$ref: '#/components/schemas/TelemetrystoretypesMergeTreeRead'
type: array
selected:
format: int64
type: integer
skipped:
format: int64
type: integer
required:
- initial
- selected
- skipped
- reads
type: object
TelemetrystoretypesIndexStep:
properties:
condition:
type: string
initialGranules:
format: int64
type: integer
initialParts:
format: int64
type: integer
keys:
items:
type: string
type: array
name:
type: string
selectedGranules:
format: int64
type: integer
selectedParts:
format: int64
type: integer
type:
type: string
required:
- type
- name
- keys
- condition
- initialParts
- selectedParts
- initialGranules
- selectedGranules
type: object
TelemetrystoretypesMergeTreeRead:
properties:
steps:
items:
$ref: '#/components/schemas/TelemetrystoretypesIndexStep'
type: array
table:
type: string
required:
- table
- steps
type: object
TelemetrytypesFieldContext:
enum:
- metric
@@ -11610,68 +11821,6 @@ paths:
summary: Get role
tags:
- role
patch:
deprecated: true
description: This endpoint patches a role
operationId: PatchRole
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
description: No Content
"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
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch role
tags:
- role
put:
deprecated: false
description: This endpoint updates a role
@@ -11734,158 +11883,6 @@ paths:
summary: Update role
tags:
- role
/api/v1/roles/{id}/relations/{relation}/objects:
get:
deprecated: false
description: Gets all objects connected to the specified role via a given relation
type
operationId: GetObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/CoretypesObjectGroup'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"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
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:read
- tokenizer:
- role:read
summary: Get objects for a role by relation
tags:
- role
patch:
deprecated: true
description: Patches the objects connected to the specified role via a given
relation type
operationId: PatchObjects
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: relation
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"451":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unavailable For Legal Reasons
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Implemented
security:
- api_key:
- role:update
- tokenizer:
- role:update
summary: Patch objects for a role by relation
tags:
- role
/api/v1/route_policies:
get:
deprecated: false
@@ -23410,6 +23407,75 @@ paths:
summary: Query range
tags:
- querier
/api/v5/query_range/preview:
post:
deprecated: false
description: 'Validate a composite query without executing it. Accepts the same
payload as the query range endpoint. By default (verbose=true) returns, for
each query, the rendered underlying ClickHouse statement(s) with each statement''s
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight
per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse
round trips. Intended for agentic/dry-run consumption: per-query errors are
reported in the response rather than failing the whole request.'
operationId: QueryRangePreviewV5
parameters:
- in: query
name: verbose
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Query range preview
tags:
- querier
/api/v5/substitute_vars:
post:
deprecated: false

View File

@@ -260,40 +260,6 @@ func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID va
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*coretypes.Object, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*coretypes.Object, 0)
for _, objectType := range provider.registry.Types() {
if coretypes.ErrIfVerbNotValidForType(relation.Verb, objectType) != nil {
continue
}
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(coretypes.NewResourceRole(), storableRole.Name, orgID, &coretypes.VerbAssignee),
relation,
objectType,
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
return objects, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
@@ -324,39 +290,6 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Update(ctx, orgID, role)
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*coretypes.Object) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := authtypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := authtypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {

View File

@@ -0,0 +1,36 @@
package implcloudprovider
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
)
type gcpcloudprovider struct {
serviceDefinitions cloudintegrationtypes.ServiceDefinitionStore
}
func NewGCPCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore) cloudintegration.CloudProviderModule {
return &gcpcloudprovider{
serviceDefinitions: defStore,
}
}
func (g *gcpcloudprovider) BuildIntegrationConfig(ctx context.Context, account *cloudintegrationtypes.Account, services []*cloudintegrationtypes.StorableCloudIntegrationService) (*cloudintegrationtypes.ProviderIntegrationConfig, error) {
// for manual flow we don't have any integration config to return, so returning empty config for now.
return &cloudintegrationtypes.ProviderIntegrationConfig{}, nil
}
func (g *gcpcloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
// for manual flow we don't have any connection artifact to return, so returning empty artifact for now.
return &cloudintegrationtypes.ConnectionArtifact{}, nil
}
func (g *gcpcloudprovider) GetServiceDefinition(ctx context.Context, serviceID cloudintegrationtypes.ServiceID) (*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.Get(ctx, cloudintegrationtypes.CloudProviderTypeGCP, serviceID)
}
func (g *gcpcloudprovider) ListServiceDefinitions(ctx context.Context) ([]*cloudintegrationtypes.ServiceDefinition, error) {
return g.serviceDefinitions.List(ctx, cloudintegrationtypes.CloudProviderTypeGCP)
}

View File

@@ -286,7 +286,7 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return module.pkgDashboardModule.Get(ctx, orgID, id)
}
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error) {
func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error) {
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}

View File

@@ -22,6 +22,8 @@ var (
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
const sampleBucketExpr = "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalMinute(10)))) * 1000 AS bucket"
type volumeRow struct {
MetricName string
Ingested uint64
@@ -289,12 +291,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
}
ctx = c.withThreads(ctx)
orderExpr := "ingested"
switch orderBy {
case metricreductionruletypes.OrderByReducedVolume:
orderExpr = "reduced"
case metricreductionruletypes.OrderByReduction:
orderExpr = "if(ingested = 0, 0, (toFloat64(ingested) - toFloat64(reduced)) / toFloat64(ingested))"
orderExpr := "ifNull(i.samples, 0)"
if orderBy == metricreductionruletypes.OrderByReducedVolume {
orderExpr = "if(ifNull(d.samples, 0) = 0 OR ifNull(d.samples, 0) > ifNull(i.samples, 0), ifNull(i.samples, 0), ifNull(d.samples, 0))"
}
direction := "ASC"
if order == metricreductionruletypes.OrderDesc {
@@ -310,17 +309,17 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
sb.From("(SELECT arrayJoin(" + sb.Var(metricNames) + ") AS metric_name) AS base")
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, uniq(fingerprint) AS cnt FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"(SELECT metric_name, uniq(fingerprint) AS cnt, count() AS samples FROM "+ingestedTable+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name) AS i",
"base.metric_name = i.metric_name",
)
// Reduced series are spread across two type-specific tables; union the per-table distinct
// reduced_fingerprints and sum per metric (a metric only lands in the table matching its type).
sb.JoinWithOption(
sqlbuilder.LeftJoin,
"(SELECT metric_name, sum(cnt) AS cnt FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
"(SELECT metric_name, sum(cnt) AS cnt, sum(samples) AS samples FROM ("+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedLast+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
" UNION ALL "+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
"SELECT metric_name, uniq(reduced_fingerprint) AS cnt, uniq(reduced_fingerprint, unix_milli) AS samples FROM "+reducedSum+" WHERE has("+sb.Var(metricNames)+", metric_name) AND unix_milli >= "+sb.Var(startMs)+" AND unix_milli < "+sb.Var(endMs)+" AND "+strictEffectiveFrom(sb, metricNames, effectiveFrom)+" GROUP BY metric_name"+
") GROUP BY metric_name) AS d",
"base.metric_name = d.metric_name",
)
@@ -347,122 +346,186 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
return out, rows.Err()
}
func (c *clickhouse) SampleVolume(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, uint64, error) {
func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]volumeRow, error) {
if len(metricNames) == 0 {
return 0, 0, nil
return map[string]volumeRow{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.countRawSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
last, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
sum, err := c.countReducedSamples(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return 0, 0, err
}
return ingested, min(last+sum, ingested), nil
}
func (c *clickhouse) countRawSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("count()")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count ingested samples")
}
return count, nil
}
func (c *clickhouse) countReducedSamples(ctx context.Context, table string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
// Reduced tables key the series on reduced_fingerprint (not fingerprint); dedupe ReplacingMergeTree recomputes.
sb.Select("uniq(reduced_fingerprint, unix_milli)")
sb.From(table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var count uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&count); err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count reduced samples")
}
return count, nil
}
// SeriesTimeseries returns ingested vs reduced series per 60s bucket from the samples tables, gated
// to each metric's strict effective_from (see strictEffectiveFrom).
func (c *clickhouse) SeriesTimeseries(ctx context.Context, allMetrics, reducedMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
if len(allMetrics) == 0 {
return []volumePoint{}, nil
}
ctx = c.withThreads(ctx)
ingested, err := c.ingestedSeriesByBucket(ctx, allMetrics, effectiveFrom, startMs, endMs)
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
retained := make(map[int64]uint64)
if len(reducedMetrics) > 0 {
reduced, err := c.reducedSeriesByBucket(ctx, reducedMetrics, effectiveFrom, startMs, endMs)
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
out := make(map[string]volumeRow, len(metricNames))
for _, name := range metricNames {
out[name] = volumeRow{MetricName: name, Ingested: ingested[name], Reduced: last[name] + sum[name]}
}
return out, nil
}
func (c *clickhouse) countSamplesByMetric(ctx context.Context, table, countExpr string, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", countExpr)
sb.From(table)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
sb.LT("unix_milli", endMs),
}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("metric_name")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to count samples")
}
defer rows.Close()
out := make(map[string]uint64, len(metricNames))
for rows.Next() {
var (
metricName string
count uint64
)
if err := rows.Scan(&metricName, &count); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan series count")
}
out[metricName] = count
}
return out, rows.Err()
}
func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uint64, uint64, error) {
ctx = c.withThreads(ctx)
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint)", "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var series, samples uint64
if err := c.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&series, &samples); err != nil {
return 0, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to count total ingested volume")
}
return series, samples, nil
}
func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string, effectiveFrom map[string]int64, startMs, endMs int64) ([]volumePoint, error) {
ctx = c.withThreads(ctx)
ingested, err := c.totalSamplesByBucket(ctx, startMs, endMs)
if err != nil {
return nil, err
}
ruledIngested := make(map[int64]uint64)
ruledRetained := make(map[int64]uint64)
if len(ruledMetrics) > 0 {
ruledIngested, err = c.ruledIngestedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range reduced {
retained[ts] += count
}
}
reducedSet := make(map[string]struct{}, len(reducedMetrics))
for _, name := range reducedMetrics {
reducedSet[name] = struct{}{}
}
nonReduced := make([]string, 0, len(allMetrics))
for _, name := range allMetrics {
if _, ok := reducedSet[name]; !ok {
nonReduced = append(nonReduced, name)
}
}
if len(nonReduced) > 0 {
nonReducedIngested, err := c.ingestedSeriesByBucket(ctx, nonReduced, effectiveFrom, startMs, endMs)
ruledRetained, err = c.ruledRetainedSamplesByBucket(ctx, ruledMetrics, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
for ts, count := range nonReducedIngested {
retained[ts] += count
}
retained := make(map[int64]uint64, len(ingested))
for ts, total := range ingested {
shed := uint64(0)
if ri := ruledIngested[ts]; ri > ruledRetained[ts] {
shed = ri - ruledRetained[ts]
}
if total > shed {
retained[ts] = total - shed
} else {
retained[ts] = 0
}
}
return mergeVolumePoints(ingested, retained), nil
}
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
buckets := make(map[int64]struct{}, len(ingested))
for ts := range ingested {
@@ -488,60 +551,6 @@ func mergeVolumePoints(ingested, reduced map[int64]uint64) []volumePoint {
return points
}
// ingestedSeriesByBucket counts distinct raw fingerprints per hourly bucket from the samples buffer.
func (c *clickhouse) ingestedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
return c.scanBuckets(ctx, sb)
}
// reducedSeriesByBucket counts distinct reduced_fingerprints per hourly bucket, summed across the two
// reduced sample tables (a metric only lands in the table matching its type, so per-bucket sums are
// exact).
func (c *clickhouse) reducedSeriesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
}
sb := sqlbuilder.NewSelectBuilder()
bucketExpr := "toInt64(toUnixTimestamp(toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalHour(1)))) * 1000 AS bucket"
sb.Select(bucketExpr, "uniq(reduced_fingerprint)")
sb.From(telemetrymetrics.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
}
sb.Where(conds...)
sb.GroupBy("bucket")
counts, err := c.scanBuckets(ctx, sb)
if err != nil {
return nil, err
}
for ts, count := range counts {
out[ts] += count
}
}
return out, nil
}
func (c *clickhouse) scanBuckets(ctx context.Context, sb *sqlbuilder.SelectBuilder) (map[int64]uint64, error) {
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)

View File

@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"sort"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -28,9 +27,16 @@ import (
const (
// effectiveFromMargin delays effective_from so the collector picks up the synced rule before it
// goes live; it must be >= the collector's rule-refresh interval (see signoz-otel-collector#839).
effectiveFromMargin = 5 * time.Minute
defaultPreviewLookback = 24 * time.Hour
// goes live; it must be >= the collector's rule-refresh interval (~2m worst case,
// see signoz-otel-collector#839).
effectiveFromMargin = 2 * time.Minute
// uiActivationDelay keeps a rule shown as "pending" in the UI for a while after it goes live to
// the collector, so the user doesn't see "active" before reduced data is actually flowing. The
// user-facing pending window is effectiveFromMargin + uiActivationDelay (~5m).
uiActivationDelay = 3 * time.Minute
defaultPreviewLookback = 1 * time.Hour
statsLookback = 1 * time.Hour
timeseriesLookback = 6 * time.Hour
pricePerMillionSamplesUSD = 0.1
monthDuration = 30 * 24 * time.Hour
@@ -80,7 +86,7 @@ func (m *module) List(ctx context.Context, orgID valuer.UUID, params *metricredu
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
startMs := now.Add(-statsLookback).UnixMilli()
endMs := now.UnixMilli()
switch params.OrderBy {
@@ -107,10 +113,14 @@ func (m *module) listSortedByColumn(ctx context.Context, orgID valuer.UUID, para
if err != nil {
return nil, err
}
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(domainRules))
for _, rule := range domainRules {
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName]))
rules = append(rules, withVolume(toGettableReductionRule(rule), volumes[rule.MetricName], sampleVolumes[rule.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
@@ -139,13 +149,24 @@ func (m *module) listSortedByVolume(ctx context.Context, orgID valuer.UUID, para
return nil, err
}
pageMetricNames := make([]string, 0, len(ranked))
for _, row := range ranked {
pageMetricNames = append(pageMetricNames, row.MetricName)
}
// TODO(srikanthccv): do we need to run this query? can we just get the same from RankByVolume?
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, pageMetricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
rules := make([]metricreductionruletypes.GettableReductionRule, 0, len(ranked))
for _, row := range ranked {
rule, ok := ruleByMetric[row.MetricName]
if !ok {
continue
}
rules = append(rules, withVolume(toGettableReductionRule(rule), row))
rules = append(rules, withVolume(toGettableReductionRule(rule), row, sampleVolumes[row.MetricName]))
}
return &metricreductionruletypes.GettableReductionRules{Rules: rules, Total: total}, nil
@@ -288,20 +309,17 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
startMs := now.Add(-statsLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, total, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
rules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
if err != nil {
return nil, err
}
if total == 0 {
return &metricreductionruletypes.GettableReductionRuleStats{}, nil
}
metricNames := make([]string, len(allRules))
effectiveFrom := make(map[string]int64, len(allRules))
for i, rule := range allRules {
metricNames := make([]string, len(rules))
effectiveFrom := make(map[string]int64, len(rules))
for i, rule := range rules {
metricNames[i] = rule.MetricName
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
@@ -310,31 +328,43 @@ func (m *module) Stats(ctx context.Context, orgID valuer.UUID) (*metricreduction
if err != nil {
return nil, err
}
var ingestedSeries, retainedSeries uint64
reducedMetricNames := make([]string, 0, len(volumes))
reducedEffectiveFrom := make(map[string]int64, len(volumes))
for name, volume := range volumes {
ingestedSeries += volume.Ingested
retained := effectiveRetained(volume.Ingested, volume.Reduced)
retainedSeries += retained
if retained < volume.Ingested {
reducedMetricNames = append(reducedMetricNames, name)
reducedEffectiveFrom[name] = effectiveFrom[name]
}
var ruledIngestedSeries, ruledRetainedSeries uint64
for _, volume := range volumes {
ruledIngestedSeries += volume.Ingested
ruledRetainedSeries += effectiveRetained(volume.Ingested, volume.Reduced)
}
ingestedSamples, reducedSamples, err := m.ch.SampleVolume(ctx, reducedMetricNames, reducedEffectiveFrom, startMs, endMs)
sampleVolumes, err := m.ch.SampleVolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
var ruledIngestedSamples, ruledRetainedSamples uint64
for _, sv := range sampleVolumes {
ruledIngestedSamples += sv.Ingested
ruledRetainedSamples += effectiveRetained(sv.Ingested, sv.Reduced)
}
totalSeries, totalSamples, err := m.ch.TotalVolume(ctx, startMs, endMs)
if err != nil {
return nil, err
}
return &metricreductionruletypes.GettableReductionRuleStats{
IngestedSeries: ingestedSeries,
RetainedSeries: retainedSeries,
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ingestedSamples, reducedSamples, startMs, endMs),
IngestedSeries: totalSeries,
RetainedSeries: clampSub(totalSeries, ruledIngestedSeries-ruledRetainedSeries),
IngestedSamples: totalSamples,
RetainedSamples: clampSub(totalSamples, ruledIngestedSamples-ruledRetainedSamples),
EstimatedMonthlySavingsUsd: monthlySavingsUSD(ruledIngestedSamples, ruledRetainedSamples, startMs, endMs),
}, nil
}
func clampSub(a, b uint64) uint64 {
if a < b {
return 0
}
return a - b
}
// monthlySavingsUSD extrapolates the windowed sample reduction to a monthly figure at the per-sample
// list price. Ingested is gated to effective_from upstream, so pre-activation hours don't inflate it.
func monthlySavingsUSD(ingestedSamples, reducedSamples uint64, startMs, endMs int64) float64 {
@@ -352,7 +382,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
}
now := time.Now()
startMs := now.Add(-defaultPreviewLookback).UnixMilli()
startMs := now.Add(-timeseriesLookback).UnixMilli()
endMs := now.UnixMilli()
allRules, _, err := m.store.List(ctx, orgID, &metricreductionruletypes.ListReductionRulesParams{})
@@ -366,18 +396,7 @@ func (m *module) Timeseries(ctx context.Context, orgID valuer.UUID) (*querybuild
effectiveFrom[rule.MetricName] = rule.EffectiveFrom.UnixMilli()
}
volumes, err := m.ch.VolumeByMetric(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
reducedNames := make([]string, 0, len(volumes))
for name, volume := range volumes {
if effectiveRetained(volume.Ingested, volume.Reduced) < volume.Ingested {
reducedNames = append(reducedNames, name)
}
}
points, err := m.ch.SeriesTimeseries(ctx, metricNames, reducedNames, effectiveFrom, startMs, endMs)
points, err := m.ch.SampleTimeseries(ctx, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
@@ -414,7 +433,7 @@ func buildVolumeTimeseries(points []volumePoint) *querybuildertypesv5.QueryRange
}
func (m *module) validateMetricForReduction(ctx context.Context, orgID valuer.UUID, metricName string) error {
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, metricName)
lastSeen, err := m.metadataStore.FetchLastSeenInfoMulti(ctx, orgID, metricName)
if err != nil {
return err
}
@@ -447,12 +466,12 @@ func (m *module) relatedAssetImpact(ctx context.Context, orgID valuer.UUID, metr
m.logger.WarnContext(ctx, "failed to fetch related dashboards for reduction preview", slog.String("metric_name", metricName), errors.Attr(err))
} else {
for _, item := range dashboards[metricName] {
usedLabels := append(splitCSV(item["group_by"]), splitCSV(item["filter_by"])...)
usedLabels := append(append([]string{}, item.GroupBy...), item.FilterBy...)
affected = append(affected, metricreductionruletypes.AffectedAsset{
Type: metricreductionruletypes.AssetTypeDashboard,
ID: item["dashboard_id"],
Name: item["dashboard_name"],
Widget: &metricreductionruletypes.AffectedWidget{ID: item["widget_id"], Name: item["widget_name"]},
ID: item.DashboardID,
Name: item.DashboardName,
Widget: &metricreductionruletypes.AffectedWidget{ID: item.PanelID, Name: item.PanelName},
ImpactedLabels: intersectLabels(usedLabels, droppedSet),
})
}
@@ -482,7 +501,7 @@ func toGettableReductionRule(rule *metricreductionruletypes.ReductionRule) metri
MatchType: rule.MatchType,
Labels: rule.Labels,
EffectiveFrom: rule.EffectiveFrom,
Active: !rule.EffectiveFrom.After(time.Now()),
Active: !rule.EffectiveFrom.Add(uiActivationDelay).After(time.Now()),
}
}
@@ -493,12 +512,11 @@ func effectiveRetained(ingested, reduced uint64) uint64 {
return reduced
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, volume volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = volume.Ingested
rule.RetainedSeries = effectiveRetained(volume.Ingested, volume.Reduced)
if volume.Ingested > 0 {
rule.ReductionPercent = (1 - float64(rule.RetainedSeries)/float64(volume.Ingested)) * 100
}
func withVolume(rule metricreductionruletypes.GettableReductionRule, series volumeRow, samples volumeRow) metricreductionruletypes.GettableReductionRule {
rule.IngestedSeries = series.Ingested
rule.RetainedSeries = effectiveRetained(series.Ingested, series.Reduced)
rule.IngestedSamples = samples.Ingested
rule.RetainedSamples = effectiveRetained(samples.Ingested, samples.Reduced)
return rule
}
@@ -518,13 +536,6 @@ func intersectLabels(keys []string, droppedSet map[string]struct{}) []string {
return out
}
func splitCSV(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
func resolveDroppedKept(matchType metricreductionruletypes.MatchType, ruleLabels, keys []string) (dropped, kept []string) {
ruleSet := make(map[string]struct{}, len(ruleLabels))
for _, l := range ruleLabels {

View File

@@ -35,7 +35,7 @@ func (s *store) List(ctx context.Context, orgID valuer.UUID, params *metricreduc
Where("org_id = ?", orgID).
Order(column + " " + direction)
if params.Search != "" {
query = query.Where("metric_name LIKE ?", "%"+params.Search+"%")
query = query.Where("metric_name LIKE ? ESCAPE '\\'", "%"+s.sqlstore.Formatter().EscapeLikePattern(params.Search)+"%")
}
if params.MetricName != "" {
query = query.Where("metric_name = ?", params.MetricName)

View File

@@ -101,6 +101,10 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRange(rw, req)
}
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRangePreview(rw, req)
}
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRawStream(rw, req)
}

View File

@@ -64,6 +64,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
signoz.Prometheus,
signoz.TelemetryStore.Cluster(),
signoz.Cache,
signoz.Flagger,
nil,
)

View File

@@ -328,6 +328,11 @@
{
"name": "immer",
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
},
{
"name": "api/generated/services/dashboard",
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
}
]
}

View File

@@ -61,5 +61,7 @@
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}

View File

@@ -86,5 +86,7 @@
"ROLE_EDIT": "SigNoz | Edit Role",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
}

View File

@@ -12,6 +12,8 @@ import type {
} from 'react-query';
import type {
QueryRangePreviewV5200,
QueryRangePreviewV5Params,
QueryRangeV5200,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
@@ -104,6 +106,107 @@ export const useQueryRangeV5 = <
> => {
return useMutation(getQueryRangeV5MutationOptions(options));
};
/**
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
* @summary Query range preview
*/
export const queryRangePreviewV5 = (
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
params?: QueryRangePreviewV5Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangePreviewV5200>({
url: `/api/v5/query_range/preview`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
params,
signal,
});
};
export const getQueryRangePreviewV5MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
const mutationKey = ['queryRangePreviewV5'];
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 queryRangePreviewV5>>,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
}
> = (props) => {
const { data, params } = props ?? {};
return queryRangePreviewV5(data, params);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangePreviewV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangePreviewV5>>
>;
export type QueryRangePreviewV5MutationBody =
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
| undefined;
export type QueryRangePreviewV5MutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Query range preview
*/
export const useQueryRangePreviewV5 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangePreviewV5>>,
TError,
{
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: QueryRangePreviewV5Params;
},
TContext
> => {
return useMutation(getQueryRangePreviewV5MutationOptions(options));
};
/**
* Replace variables in a query
* @summary Replace variables

View File

@@ -18,19 +18,13 @@ import type {
} from 'react-query';
import type {
AuthtypesPatchableRoleDTO,
AuthtypesPostableRoleDTO,
AuthtypesUpdatableRoleDTO,
CoretypesPatchableObjectsDTO,
CreateRole201,
DeleteRolePathParameters,
GetObjects200,
GetObjectsPathParameters,
GetRole200,
GetRolePathParameters,
ListRoles200,
PatchObjectsPathParameters,
PatchRolePathParameters,
RenderErrorResponseDTO,
UpdateRolePathParameters,
} from '../sigNoz.schemas';
@@ -365,107 +359,6 @@ export const invalidateGetRole = async (
return queryClient;
};
/**
* This endpoint patches a role
* @deprecated
* @summary Patch role
*/
export const patchRole = (
{ id }: PatchRolePathParameters,
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: authtypesPatchableRoleDTO,
signal,
});
};
export const getPatchRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
const mutationKey = ['patchRole'];
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 patchRole>>,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof patchRole>>
>;
export type PatchRoleMutationBody =
| BodyType<AuthtypesPatchableRoleDTO>
| undefined;
export type PatchRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch role
*/
export const usePatchRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchRole>>,
TError,
{
pathParams: PatchRolePathParameters;
data?: BodyType<AuthtypesPatchableRoleDTO>;
},
TContext
> => {
return useMutation(getPatchRoleMutationOptions(options));
};
/**
* This endpoint updates a role
* @summary Update role
@@ -565,205 +458,3 @@ export const useUpdateRole = <
> => {
return useMutation(getUpdateRoleMutationOptions(options));
};
/**
* Gets all objects connected to the specified role via a given relation type
* @summary Get objects for a role by relation
*/
export const getObjects = (
{ id, relation }: GetObjectsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetObjects200>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'GET',
signal,
});
};
export const getGetObjectsQueryKey = ({
id,
relation,
}: GetObjectsPathParameters) => {
return [`/api/v1/roles/${id}/relations/${relation}/objects`] as const;
};
export const getGetObjectsQueryOptions = <
TData = Awaited<ReturnType<typeof getObjects>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, relation }: GetObjectsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getObjects>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetObjectsQueryKey({ id, relation });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getObjects>>> = ({
signal,
}) => getObjects({ id, relation }, signal);
return {
queryKey,
queryFn,
enabled: !!(id && relation),
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof getObjects>>, TError, TData> & {
queryKey: QueryKey;
};
};
export type GetObjectsQueryResult = NonNullable<
Awaited<ReturnType<typeof getObjects>>
>;
export type GetObjectsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get objects for a role by relation
*/
export function useGetObjects<
TData = Awaited<ReturnType<typeof getObjects>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, relation }: GetObjectsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getObjects>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetObjectsQueryOptions({ id, relation }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get objects for a role by relation
*/
export const invalidateGetObjects = async (
queryClient: QueryClient,
{ id, relation }: GetObjectsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetObjectsQueryKey({ id, relation }) },
options,
);
return queryClient;
};
/**
* Patches the objects connected to the specified role via a given relation type
* @deprecated
* @summary Patch objects for a role by relation
*/
export const patchObjects = (
{ id, relation }: PatchObjectsPathParameters,
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: coretypesPatchableObjectsDTO,
signal,
});
};
export const getPatchObjectsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
const mutationKey = ['patchObjects'];
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 patchObjects>>,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchObjects(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchObjectsMutationResult = NonNullable<
Awaited<ReturnType<typeof patchObjects>>
>;
export type PatchObjectsMutationBody =
| BodyType<CoretypesPatchableObjectsDTO>
| undefined;
export type PatchObjectsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Patch objects for a role by relation
*/
export const usePatchObjects = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchObjects>>,
TError,
{
pathParams: PatchObjectsPathParameters;
data?: BodyType<CoretypesPatchableObjectsDTO>;
},
TContext
> => {
return useMutation(getPatchObjectsMutationOptions(options));
};

View File

@@ -2230,13 +2230,6 @@ export interface AuthtypesOrgSessionContextDTO {
warning?: ErrorsJSONDTO;
}
export interface AuthtypesPatchableRoleDTO {
/**
* @type string
*/
description: string;
}
export interface AuthtypesPostableAuthDomainDTO {
config?: AuthtypesAuthDomainConfigDTO;
/**
@@ -2630,9 +2623,25 @@ export interface CloudintegrationtypesAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
projectIds: string[];
}
export interface CloudintegrationtypesAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesAccountDTO {
@@ -2740,9 +2749,29 @@ export interface CloudintegrationtypesAzureServiceConfigDTO {
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesGCPServiceLogsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceMetricsConfigDTO {
/**
* @type boolean
*/
enabled: boolean;
}
export interface CloudintegrationtypesGCPServiceConfigDTO {
logs?: CloudintegrationtypesGCPServiceLogsConfigDTO;
metrics?: CloudintegrationtypesGCPServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesServiceConfigDTO {
aws?: CloudintegrationtypesAWSServiceConfigDTO;
azure?: CloudintegrationtypesAzureServiceConfigDTO;
gcp?: CloudintegrationtypesGCPServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
@@ -2773,6 +2802,7 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql = 'cloudsql',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -2837,9 +2867,14 @@ export interface CloudintegrationtypesCollectedMetricDTO {
unit?: string;
}
export interface CloudintegrationtypesGCPConnectionArtifactDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
gcp?: CloudintegrationtypesGCPConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
@@ -2872,6 +2907,10 @@ export interface CloudintegrationtypesDataCollectedDTO {
metrics?: CloudintegrationtypesCollectedMetricDTO[] | null;
}
export interface CloudintegrationtypesGCPIntegrationConfigDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesGettableAccountWithConnectionArtifactDTO {
connectionArtifact: CloudintegrationtypesConnectionArtifactDTO;
/**
@@ -2963,6 +3002,7 @@ export type CloudintegrationtypesIntegrationConfigDTO =
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
gcp?: CloudintegrationtypesGCPIntegrationConfigDTO;
}
export interface CloudintegrationtypesGettableAgentCheckInDTO {
@@ -3025,6 +3065,7 @@ export interface CloudintegrationtypesGettableServicesMetadataDTO {
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
gcp?: CloudintegrationtypesGCPAccountConfigDTO;
}
export interface CloudintegrationtypesPostableAccountDTO {
@@ -3154,9 +3195,25 @@ export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
resourceGroups: string[];
}
export interface CloudintegrationtypesUpdatableGCPAccountConfigDTO {
/**
* @type string
*/
deploymentProjectId: string;
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array,null
*/
projectIds: string[] | null;
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
gcp?: CloudintegrationtypesUpdatableGCPAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
@@ -3185,17 +3242,6 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export interface CoretypesPatchableObjectsDTO {
/**
* @type array,null
*/
additions: CoretypesObjectGroupDTO[] | null;
/**
* @type array,null
*/
deletions: CoretypesObjectGroupDTO[] | null;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**
@@ -3338,6 +3384,7 @@ export interface DashboardtypesThresholdWithLabelDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -3865,6 +3912,7 @@ export interface DashboardtypesComparisonThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -3929,6 +3977,14 @@ export interface DashboardtypesDashboardPanelRefDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
filterBy?: string[];
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -4145,6 +4201,7 @@ export interface DashboardtypesTableThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -6852,6 +6909,11 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
* @type string
*/
id: string;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6867,10 +6929,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
*/
metricName: string;
/**
* @type number
* @format double
* @type integer
* @minimum 0
*/
reductionPercent: number;
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6929,11 +6991,21 @@ export interface MetricreductionruletypesGettableReductionRuleStatsDTO {
* @format double
*/
estimatedMonthlySavingsUsd: number;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
*/
ingestedSeries: number;
/**
* @type integer
* @minimum 0
*/
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6989,7 +7061,6 @@ export enum MetricreductionruletypesReductionRuleOrderByDTO {
metric = 'metric',
ingested_volume = 'ingested_volume',
reduced_volume = 'reduced_volume',
reduction = 'reduction',
last_updated = 'last_updated',
}
export interface MetricreductionruletypesUpdatableReductionRuleDTO {
@@ -7552,6 +7623,126 @@ export interface Querybuildertypesv5FormatOptionsDTO {
formatTableResultForUI?: boolean;
}
export interface TelemetrystoretypesEstimateEntryDTO {
/**
* @type string
*/
database: string;
/**
* @type integer
* @format int64
*/
marks: number;
/**
* @type integer
* @format int64
*/
parts: number;
/**
* @type integer
* @format int64
*/
rows: number;
/**
* @type string
*/
table: string;
}
export interface TelemetrystoretypesIndexStepDTO {
/**
* @type string
*/
condition: string;
/**
* @type integer
* @format int64
*/
initialGranules: number;
/**
* @type integer
* @format int64
*/
initialParts: number;
/**
* @type array
*/
keys: string[];
/**
* @type string
*/
name: string;
/**
* @type integer
* @format int64
*/
selectedGranules: number;
/**
* @type integer
* @format int64
*/
selectedParts: number;
/**
* @type string
*/
type: string;
}
export interface TelemetrystoretypesMergeTreeReadDTO {
/**
* @type array
*/
steps: TelemetrystoretypesIndexStepDTO[];
/**
* @type string
*/
table: string;
}
export type TelemetrystoretypesGranulesDTOAnyOf = {
/**
* @type integer
* @format int64
*/
initial: number;
/**
* @type array
*/
reads: TelemetrystoretypesMergeTreeReadDTO[];
/**
* @type integer
* @format int64
*/
selected: number;
/**
* @type integer
* @format int64
*/
skipped: number;
};
/**
* @nullable
*/
export type TelemetrystoretypesGranulesDTO =
TelemetrystoretypesGranulesDTOAnyOf | null;
export interface Querybuildertypesv5PreviewStatementDTO {
/**
* @type array
*/
'db.statement.args': unknown[];
/**
* @type string
*/
'db.statement.query': string;
/**
* @type array
*/
estimate: TelemetrystoretypesEstimateEntryDTO[];
granules: TelemetrystoretypesGranulesDTO | null;
}
export interface Querybuildertypesv5TimeSeriesDataDTO {
/**
* @type array,null
@@ -7633,6 +7824,41 @@ export type Querybuildertypesv5QueryDataDTO =
results?: unknown[] | null;
});
export interface Querybuildertypesv5QueryPreviewDTO {
error: unknown;
/**
* @type array
*/
statements: Querybuildertypesv5PreviewStatementDTO[];
/**
* @type boolean
*/
valid: boolean;
/**
* @type array
*/
warnings: string[];
}
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
/**
* @nullable
*/
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
/**
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
*/
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
/**
* @type object,null
*/
compositeQuery: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
}
export enum Querybuildertypesv5VariableTypeDTO {
query = 'query',
dynamic = 'dynamic',
@@ -10026,31 +10252,9 @@ export type GetRole200 = {
status: string;
};
export type PatchRolePathParameters = {
id: string;
};
export type UpdateRolePathParameters = {
id: string;
};
export type GetObjectsPathParameters = {
id: string;
relation: string;
};
export type GetObjects200 = {
/**
* @type array
*/
data: CoretypesObjectGroupDTO[];
/**
* @type string
*/
status: string;
};
export type PatchObjectsPathParameters = {
id: string;
relation: string;
};
export type GetAllRoutePolicies200 = {
/**
* @type array
@@ -11507,6 +11711,22 @@ export type QueryRangeV5200 = {
status: string;
};
export type QueryRangePreviewV5Params = {
/**
* @type string
* @description undefined
*/
verbose?: string;
};
export type QueryRangePreviewV5200 = {
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
/**
* @type string
*/
status: string;
};
export type ReplaceVariables200 = {
data: Querybuildertypesv5QueryRangeRequestDTO;
/**

View File

@@ -167,6 +167,7 @@ describe('InviteMembers - Submission', () => {
success: false,
}),
]),
expect.any(Array),
);
});
});
@@ -243,6 +244,7 @@ describe('InviteMembers - Submission', () => {
error: 'User already exists',
}),
]),
expect.any(Array),
);
await expect(

View File

@@ -22,9 +22,9 @@ export interface FooterRenderProps {
export interface UseInviteMembersOptions {
initialRowCount?: number;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
}
export interface UseInviteMembersReturn {
@@ -56,9 +56,9 @@ export interface InviteMembersProps {
showHeader?: boolean;
showAddButton?: boolean;
onSuccess?: () => void;
onPartialSuccess?: (results: InviteResult[]) => void;
onAllFailed?: (results: InviteResult[]) => void;
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
renderFooter?: (props: FooterRenderProps) => ReactNode;
}

View File

@@ -207,11 +207,11 @@ export function useInviteMembers(
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
onSuccess?.();
onSuccess?.(results, touched);
} else if (successes.length > 0) {
onPartialSuccess?.(results);
onPartialSuccess?.(results, touched);
} else {
onAllFailed?.(results);
onAllFailed?.(results, touched);
}
return results;

View File

@@ -1,254 +0,0 @@
.invite-members-modal {
max-width: 700px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--l1-foreground);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.invite-members-modal__content {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4);
}
}
}
.invite-members-modal__table {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
}
.invite-members-modal__table-header {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
.email-header {
flex: 0 0 240px;
}
.role-header {
flex: 1 0 0;
min-width: 0;
}
.action-header {
flex: 0 0 32px;
}
.table-header-cell {
font-family: Inter, sans-serif;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
color: var(--foreground);
}
}
.invite-members-modal__container {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
}
.team-member-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
width: 100%;
> .email-cell {
flex: 0 0 240px;
}
> .role-cell {
flex: 1 0 0;
min-width: 0;
}
> .action-cell {
flex: 0 0 32px;
}
}
.team-member-cell {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
&.action-cell {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
}
}
.team-member-email-input {
width: 100%;
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--l1-border);
font-size: var(--paragraph-base-400-font-size);
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
.team-member-role-select {
width: 100%;
.ant-select-selector {
height: 32px;
border-radius: 2px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
padding: 0 var(--padding-2) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground);
opacity: 0.4;
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
line-height: 32px;
}
.ant-select-selection-item {
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
color: var(--l1-foreground);
line-height: 32px;
}
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary);
}
}
.remove-team-member-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
border: none;
border-radius: 2px;
background: transparent;
color: var(--destructive);
opacity: 0.6;
padding: 0;
transition:
background-color 0.2s,
opacity 0.2s;
box-shadow: none;
&:hover {
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
opacity: 0.9;
}
}
.email-error-message {
display: block;
font-family: Inter, sans-serif;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-18);
color: var(--destructive);
}
.invite-team-members-error-callout {
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--danger-background) 20%, transparent);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
.invite-members-modal__error-callout {
display: flex;
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}
.invite-members-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: 0;
flex-shrink: 0;
}
.invite-members-modal__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.add-another-member-button {
&:hover {
border-color: var(--primary);
border-style: dashed;
color: var(--l1-foreground);
}
}

View File

@@ -1,337 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Style } from '@signozhq/design-tokens';
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { Select } from 'antd';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { EMAIL_REGEX } from 'utils/app';
import { getBaseUrl } from 'utils/basePath';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './InviteMembersModal.styles.scss';
interface InviteRow {
id: string;
email: string;
role: ROLES | '';
}
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
const isRowTouched = (row: InviteRow): boolean =>
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
function InviteMembersModal({
open,
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const { showErrorModal, isErrorModalVisible } = useErrorModal();
const [rows, setRows] = useState<InviteRow[]>(() => [
EMPTY_ROW(),
EMPTY_ROW(),
EMPTY_ROW(),
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const resetAndClose = useCallback((): void => {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
onClose();
}, [onClose]);
useEffect(() => {
if (open) {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
}
}, [open]);
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const validateAllUsers = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedRows = rows.filter(isRowTouched);
touchedRows.forEach((row) => {
const emailValid = EMAIL_REGEX.test(row.email);
const roleValid = Boolean(row.role && row.role.trim() !== '');
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (row.id) {
updatedEmailValidity[row.id] = emailValid;
}
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = EMAIL_REGEX.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
useEffect(() => {
if (!open) {
debouncedValidateEmail.cancel();
}
return (): void => {
debouncedValidateEmail.cancel();
};
}, [open, debouncedValidateEmail]);
const updateEmail = (id: string, email: string): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.email = email;
setRows(updatedRows);
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
debouncedValidateEmail(email, id);
}
};
const updateRole = (id: string, role: ROLES): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.role = role;
setRows(updatedRows);
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
}
};
const addRow = (): void => {
setRows((prev) => [...prev, EMPTY_ROW()]);
};
const removeRow = (id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
};
const handleSubmit = useCallback(async (): Promise<void> => {
if (!validateAllUsers()) {
return;
}
const touchedRows = rows.filter(isRowTouched);
if (touchedRows.length === 0) {
return;
}
setIsSubmitting(true);
try {
if (touchedRows.length === 1) {
const row = touchedRows[0];
await sendInvite({
email: row.email.trim(),
name: '',
role: row.role as ROLES,
frontendBaseUrl: getBaseUrl(),
});
} else {
await inviteUsers({
invites: touchedRows.map((row) => ({
email: row.email.trim(),
name: '',
role: row.role,
frontendBaseUrl: getBaseUrl(),
})),
});
}
toast.success('Invites sent successfully', { position: 'top-right' });
resetAndClose();
onComplete?.();
} catch (err) {
showErrorModal(err as APIError);
} finally {
setIsSubmitting(false);
}
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
resetAndClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick={isErrorModalVisible}
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
<div className="invite-members-modal__table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<div className="invite-members-modal__container">
{rows.map(
(row): JSX.Element => (
<div key={row.id} className="team-member-row">
<div className="team-member-cell email-cell">
<Input
type="email"
placeholder="john@signoz.io"
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
name={`invite-email-${row.id}`}
autoComplete="email"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<span className="email-error-message">Invalid email address</span>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={row.role || undefined}
onChange={(role): void => updateRole(row.id, role as ROLES)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={popupContainer}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{rows.length > 1 && (
<Button
variant="ghost"
color="destructive"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
),
)}
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<div className="invite-members-modal__error-callout">
<Callout
type="error"
size="small"
showIcon
title={getValidationErrorMessage()}
/>
</div>
)}
</div>
<DialogFooter className="invite-members-modal__footer">
<Button
variant="dashed"
color="secondary"
className="add-another-member-button"
prefix={<Plus size={12} color={Style.L1_FOREGROUND} />}
onClick={addRow}
>
Add another
</Button>
<div className="invite-members-modal__footer-right">
<Button
type="button"
variant="solid"
color="secondary"
onClick={resetAndClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={isSubmitDisabled}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
</DialogFooter>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -1,276 +0,0 @@
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import InviteMembersModal from '../InviteMembersModal';
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
new APIError({
httpStatusCode: code,
error: { code: 'already_exists', message, url: '', errors: [] },
});
jest.mock('api/v1/invite/create');
jest.mock('api/v1/invite/bulk/create');
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const showErrorModal = jest.fn();
jest.mock('providers/ErrorModalProvider', () => ({
__esModule: true,
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal,
isErrorModalVisible: false,
})),
}));
const mockSendInvite = jest.mocked(sendInvite);
const mockInviteUsers = jest.mocked(inviteUsers);
const defaultProps = {
open: true,
onClose: jest.fn(),
onComplete: jest.fn(),
};
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
showErrorModal.mockClear();
mockSendInvite.mockResolvedValue({
httpStatusCode: 200,
data: { data: 'test', status: 'success' },
});
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
});
it('renders 3 initial empty rows and disables the submit button', () => {
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
expect(emailInputs).toHaveLength(3);
expect(
screen.getByRole('button', { name: /invite team members/i }),
).toBeDisabled();
});
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
await user.click(removeButtons[0]);
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
});
describe('validation callout messages', () => {
it('shows combined message when email is invalid and role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'not-an-email',
);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await expect(
screen.findByText(
'Please enter valid emails and select roles for team members',
),
).resolves.toBeInTheDocument();
});
it('shows email-only message when email is invalid but role is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'not-an-email');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await expect(
screen.findByText('Please enter valid emails for team members'),
).resolves.toBeInTheDocument();
});
it('shows role-only message when email is valid but role is missing', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'valid@signoz.io',
);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await expect(
screen.findByText('Please select roles for team members'),
).resolves.toBeInTheDocument();
});
});
it('uses sendInvite (single) when only one row is filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'single@signoz.io');
const roleSelects = screen.getAllByText('Select roles');
await user.click(roleSelects[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(mockSendInvite).toHaveBeenCalledWith(
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
);
expect(mockInviteUsers).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: single@signoz.io',
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'An invite already exists for this email: alice@signoz.io',
);
mockInviteUsers.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const error = makeApiError(
'Internal server error',
StatusCodes.INTERNAL_SERVER_ERROR,
);
mockSendInvite.mockRejectedValue(error);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(showErrorModal).toHaveBeenCalledWith(error);
});
});
});
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(mockInviteUsers).toHaveBeenCalledWith({
invites: expect.arrayContaining([
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
]),
});
expect(mockSendInvite).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
});

View File

@@ -3,7 +3,6 @@
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}

View File

@@ -32,10 +32,13 @@ export function useRoles(): {
};
}
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
export function getRoleOptions(
roles: AuthtypesRoleDTO[],
valueField: 'id' | 'name',
): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.id ?? '',
value: role[valueField] ?? '',
}));
}
@@ -82,6 +85,7 @@ interface BaseProps {
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
valueField?: 'id' | 'name';
}
interface SingleProps extends BaseProps {
@@ -113,7 +117,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles);
const options = getRoleOptions(roles, props.valueField || 'id');
const {
mode,

View File

@@ -0,0 +1,90 @@
.container {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
// Sienna chip — matches the dashboard list-row tag badge.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
&::placeholder {
color: var(--l3-foreground);
}
}
.editInput {
width: 160px;
height: 24px;
}
.error {
color: var(--bg-cherry-500);
font-size: 12px;
}

View File

@@ -0,0 +1,164 @@
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
interface TagKeyValueInputProps {
// Tags as `key:value` strings.
tags: string[];
onTagsChange: (tags: string[]) => void;
placeholder?: string;
// Override the outer container styling per host (e.g. the create modal).
className?: string;
testId?: string;
}
// Strict key:value tag editor. A tag is committed only on Enter and only when
// it parses to a valid `key:value` pair — bare values are rejected with an
// inline error. Existing chips can be edited inline (double-click), and removed.
function TagKeyValueInput({
tags,
onTagsChange,
placeholder = 'key:value',
className,
testId = 'tag-key-value-input',
}: TagKeyValueInputProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const [error, setError] = useState('');
const [editIndex, setEditIndex] = useState(-1);
const [editValue, setEditValue] = useState('');
const removeTag = (tag: string): void => {
onTagsChange(tags.filter((t) => t !== tag));
};
const commit = (): void => {
const raw = inputValue.trim();
if (!raw) {
return;
}
const normalized = parseKeyValueTag(raw);
if (!normalized) {
setError('Tags must be in key:value format (both sides required).');
return;
}
if (tags.includes(normalized)) {
setError('This tag already exists.');
return;
}
onTagsChange([...tags, normalized]);
setInputValue('');
setError('');
};
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
if (error) {
setError('');
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
};
const startEdit = (index: number): void => {
setEditIndex(index);
setEditValue(tags[index]);
setError('');
};
const cancelEdit = (): void => {
setEditIndex(-1);
setEditValue('');
};
const commitEdit = (): void => {
const normalized = parseKeyValueTag(editValue);
// Drop into a no-op (revert) on invalid or duplicate edits rather than
// stranding the user in an un-exitable edit box.
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
}
cancelEdit();
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
return (
<div className={cx(styles.container, className)}>
<div className={styles.field}>
{tags.map((tag, index) =>
index === editIndex ? (
<Input
key={tag}
className={styles.editInput}
value={editValue}
autoFocus
testId={`${testId}-edit`}
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
setEditValue(e.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
/>
) : (
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
),
)}
<Input
className={styles.input}
value={inputValue}
placeholder={placeholder}
testId={testId}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
</div>
{error && (
<Typography className={styles.error} data-testid={`${testId}-error`}>
{error}
</Typography>
)}
</div>
);
}
export default TagKeyValueInput;

View File

@@ -0,0 +1,31 @@
import { parseKeyValueTag } from './utils';
describe('parseKeyValueTag', () => {
it('normalizes a valid key:value pair', () => {
expect(parseKeyValueTag('env:prod')).toBe('env:prod');
});
it('trims whitespace around key and value', () => {
expect(parseKeyValueTag(' env : prod ')).toBe('env:prod');
});
it('keeps colons inside the value', () => {
expect(parseKeyValueTag('url:http://x')).toBe('url:http://x');
});
it('rejects a bare value with no colon', () => {
expect(parseKeyValueTag('prod')).toBeNull();
});
it('rejects an empty key', () => {
expect(parseKeyValueTag(':prod')).toBeNull();
});
it('rejects an empty value', () => {
expect(parseKeyValueTag('env:')).toBeNull();
});
it('rejects blank input', () => {
expect(parseKeyValueTag(' ')).toBeNull();
});
});

View File

@@ -0,0 +1,17 @@
// Tags are strictly key:value. Parse a raw input into a normalized `key:value`
// string, or null if it isn't a valid pair (both sides non-empty). The first
// colon separates key from value, so values may themselves contain colons
// (e.g. `url:http://x`).
export function parseKeyValueTag(raw: string): string | null {
const trimmed = raw.trim();
const idx = trimmed.indexOf(':');
if (idx <= 0) {
return null;
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (!key || !value) {
return null;
}
return `${key}:${value}`;
}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { SolidAlertTriangle } from '@signozhq/icons';
import { Select, Tooltip } from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import classNames from 'classnames';
import cx from 'classnames';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
@@ -72,9 +72,7 @@ function YAxisUnitSelector({
}, [categoriesOverride, source]);
return (
<div
className={classNames('y-axis-unit-selector-component', containerClassName)}
>
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
<Select
showSearch
value={universalUnit}
@@ -84,12 +82,17 @@ function YAxisUnitSelector({
loading={loading}
suffixIcon={
incompatibleUnitMessage ? (
<Tooltip title={incompatibleUnitMessage}>
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
<Tooltip
title={incompatibleUnitMessage}
overlayClassName="y-axis-unit-warning-tooltip"
>
<span className="y-axis-unit-warning" role="img" aria-label="warning">
<SolidAlertTriangle size="md" />
</span>
</Tooltip>
) : undefined
}
className={classNames({
className={cx({
'warning-state': incompatibleUnitMessage,
})}
data-testid={dataTestId}

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisSource } from '../types';
@@ -6,9 +7,13 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
const mockOnChange = jest.fn();
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
mockOnChange.mockClear();
user = userEvent.setup({ pointerEventsCheck: 0 });
});
it('renders with default placeholder', () => {
@@ -34,7 +39,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
});
it('calls onChange when a value is selected', () => {
it('calls onChange when a value is selected', async () => {
render(
<YAxisUnitSelector
value=""
@@ -44,9 +49,8 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
const option = screen.getByText('Bytes (B)');
fireEvent.click(option);
await user.click(select);
await user.click(screen.getByText('Bytes (B)'));
expect(mockOnChange).toHaveBeenCalledWith('By', {
children: 'Bytes (B)',
@@ -55,7 +59,7 @@ describe('YAxisUnitSelector', () => {
});
});
it('filters options based on search input', () => {
it('filters options based on search input', async () => {
render(
<YAxisUnitSelector
value=""
@@ -65,14 +69,13 @@ describe('YAxisUnitSelector', () => {
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'bytes/sec' } });
await user.click(select);
await user.type(select, 'bytes/sec');
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
});
it('shows all categories and their units', () => {
it('shows all categories and their units', async () => {
render(
<YAxisUnitSelector
value=""
@@ -80,9 +83,8 @@ describe('YAxisUnitSelector', () => {
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
await user.click(screen.getByRole('combobox'));
// Check for category headers
expect(screen.getByText('Data')).toBeInTheDocument();
@@ -93,7 +95,7 @@ describe('YAxisUnitSelector', () => {
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
});
it('shows warning message when incompatible unit is selected', () => {
it('shows warning message when incompatible unit is selected', async () => {
render(
<YAxisUnitSelector
source={YAxisSource.ALERTS}
@@ -104,12 +106,12 @@ describe('YAxisUnitSelector', () => {
);
const warningIcon = screen.getByLabelText('warning');
expect(warningIcon).toBeInTheDocument();
fireEvent.mouseOver(warningIcon);
return screen
.findByText(
await user.hover(warningIcon);
await expect(
screen.findByText(
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
)
.then((el) => expect(el).toBeInTheDocument());
),
).resolves.toBeInTheDocument();
});
it('does not show warning message when compatible unit is selected', () => {
@@ -125,7 +127,7 @@ describe('YAxisUnitSelector', () => {
expect(warningIcon).not.toBeInTheDocument();
});
it('uses categories override to render custom units', () => {
it('uses categories override to render custom units', async () => {
const customCategories = [
{
name: YAxisCategoryNames.Data,
@@ -147,9 +149,7 @@ describe('YAxisUnitSelector', () => {
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
await user.click(screen.getByRole('combobox'));
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();

View File

@@ -680,6 +680,13 @@ describe('formatUniversalUnit', () => {
});
describe('Datetime', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z'));
});
afterAll(() => {
jest.useRealTimers();
});
it('formats datetime units', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
'56 years ago',

View File

@@ -4,6 +4,13 @@
}
}
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
// `pointer-events: none`, which would otherwise suppress the tooltip.
.y-axis-unit-warning {
display: inline-flex;
pointer-events: auto;
}
.warning-state {
.ant-select-selector {
border-color: var(--bg-amber-400) !important;
@@ -17,3 +24,7 @@
right: 28px;
}
}
.y-axis-unit-warning-tooltip {
max-width: 240px;
}

View File

@@ -1,7 +1,28 @@
.filtersBar {
display: flex;
gap: var(--spacing-6);
align-items: center;
justify-content: space-between;
}
.filtersBarLeft {
display: flex;
gap: var(--spacing-6);
align-items: center;
}
.filtersBarSearch {
width: 280px;
}
.filtersBarSource {
width: 160px;
}
.pageError {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
color: var(--text-cherry-400);
background: color-mix(in srgb, var(--accent-cherry) 8%, transparent);
color: var(--accent-cherry);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,52 +1,164 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Search, X } from '@signozhq/icons';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
import { useTableParams } from 'components/TanStackTableView';
import { Typography } from '@signozhq/ui/typography';
import useComponentPermission from 'hooks/useComponentPermission';
import useDebounce from 'hooks/useDebounce';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
import { useAppContext } from 'providers/App/App';
import { LIMIT_KEY, PAGE_KEY, PAGE_SIZE } from '../constants';
import styles from './ModelCostTabPanel.module.scss';
import {
LIMIT_KEY,
PAGE_KEY,
PAGE_SIZE,
SEARCH_DEBOUNCE_MS,
SEARCH_KEY,
SOURCE_FILTER_OPTIONS,
SOURCE_FILTER_TO_IS_OVERRIDE,
SOURCE_KEY,
type SourceFilter,
} from '../constants';
import type { PricingRule } from '../types';
import DeleteConfirmDialog from './components/DeleteConfirmDialog';
import ModelCostDrawer, {
useModelCostDrawer,
} from './components/ModelCostDrawer';
import ModelCostsTable from './components/ModelCostsTable';
import { type LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useModelCostDelete } from './hooks/useModelCostDelete';
import styles from './ModelCostTabPanel.module.scss';
// "Model costs" tab: the priced-model listing, search + source filter, the add/
// edit drawer, and pagination. Page and page size live in the URL (shareable/
// reload-safe) and are owned by TanStackTable via enableQueryParams — this tab
// reads them back through the same useTableParams hook so the two stay in lockstep.
function ModelCostTabPanel(): JSX.Element {
const { page, limit } = useTableParams(
const { page, limit, setPage } = useTableParams(
{ page: PAGE_KEY, limit: LIMIT_KEY },
{ page: 1, limit: PAGE_SIZE },
);
// Search + source filters are intentionally omitted for now — the list API
// doesn't honour them yet. They'll be reintroduced here once it does.
const [search, setSearch] = useQueryState(
SEARCH_KEY,
parseAsString.withDefault(''),
);
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const [source, setSource] = useQueryState(
SOURCE_KEY,
parseAsStringEnum<SourceFilter>(
SOURCE_FILTER_OPTIONS.map((option) => option.value),
).withDefault('all'),
);
const handleSearchChange = (
event: React.ChangeEvent<HTMLInputElement>,
): void => {
void setSearch(event.target.value || null);
setPage(1);
};
const clearSearch = (): void => {
void setSearch(null);
setPage(1);
};
const handleSourceChange = (value: string | string[]): void => {
void setSource(value as SourceFilter);
setPage(1);
};
const isOverride = SOURCE_FILTER_TO_IS_OVERRIDE[source];
const listParams: ListLLMPricingRulesParams = {
offset: (page - 1) * limit,
limit,
...(debouncedSearch ? { q: debouncedSearch } : {}),
...(isOverride !== undefined ? { isOverride } : {}),
};
const { data, isLoading, isError } = useListLLMPricingRules(listParams);
const { data, isLoading, isError } = useListLLMPricingRules(listParams, {
query: {
enabled: search === debouncedSearch,
},
});
const rules: LlmpricingruletypesLLMPricingRuleDTO[] = useMemo(
() => data?.data?.items || [],
[data],
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const rules: PricingRule[] = useMemo(() => data?.data?.items || [], [data]);
const total = data?.data?.total ?? 0;
const drawer = useModelCostDrawer();
const deletion = useModelCostDelete();
return (
<>
<div className={styles.filtersBar}>
<div className={styles.filtersBarLeft}>
<Input
className={styles.filtersBarSearch}
placeholder="Search by model or provider"
value={search}
onChange={handleSearchChange}
prefix={<Search size={14} />}
suffix={
search ? (
<Button
variant="ghost"
color="secondary"
size="icon"
prefix={<X size={14} />}
onClick={clearSearch}
aria-label="Clear search"
testId="model-cost-search-clear"
/>
) : undefined
}
testId="model-cost-search"
/>
<SelectSimple
className={styles.filtersBarSource}
items={SOURCE_FILTER_OPTIONS}
value={source}
onChange={handleSourceChange}
testId="source-filter"
/>
</div>
{canManagePricing && (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => drawer.openForAdd()}
testId="add-model-cost-btn"
>
Add model cost
</Button>
)}
</div>
{isError && (
<div className={styles.pageError} role="alert">
Failed to load pricing rules. Please try again.
</div>
)}
{/* Read-only listing. Edit/Add wiring + the drawer land in the next PR. */}
<ModelCostsTable
rules={rules}
isLoading={isLoading}
total={total}
selectedRuleId={null}
canManage={false}
onEdit={(): void => undefined}
onDelete={(): void => undefined}
selectedRuleId={drawer.selectedRuleId}
canManage={canManagePricing}
onEdit={drawer.openForEdit}
onDelete={deletion.requestDelete}
/>
<footer>
@@ -54,6 +166,29 @@ function ModelCostTabPanel(): JSX.Element {
All prices per 1M tokens (USD)
</Typography.Text>
</footer>
{drawer.isOpen && (
<ModelCostDrawer
isOpen={drawer.isOpen}
mode={drawer.mode}
initialDraft={drawer.initialDraft}
onClose={drawer.close}
onSave={drawer.save}
isSaving={drawer.isSaving}
saveError={drawer.saveError}
canManage={canManagePricing}
/>
)}
{deletion.pendingDelete && (
<DeleteConfirmDialog
open
modelName={deletion.pendingDelete.modelName}
isDeleting={deletion.isDeleting}
onConfirm={deletion.confirmDelete}
onCancel={deletion.cancelDelete}
/>
)}
</>
);
}

View File

@@ -0,0 +1,64 @@
import { AlertDialog } from '@signozhq/ui/alert-dialog';
import { Button } from '@signozhq/ui/button';
import { Trash2, X } from '@signozhq/icons';
interface DeleteConfirmDialogProps {
open: boolean;
modelName: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Confirmation step before deleting a model cost — deletion is irreversible, so
// the destructive action is gated behind an explicit confirm. AlertDialog blocks
// outside-click dismissal and hides the close button to force an explicit choice.
function DeleteConfirmDialog({
open,
modelName,
isDeleting,
onConfirm,
onCancel,
}: DeleteConfirmDialogProps): JSX.Element {
return (
<AlertDialog
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="narrow"
title="Delete Model Cost Data "
titleIcon={<Trash2 size={16} />}
footer={
<>
<Button
variant="solid"
color="secondary"
onClick={onCancel}
prefix={<X size={12} />}
testId="drawer-delete-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={onConfirm}
prefix={<Trash2 size={12} />}
testId="drawer-delete-confirm-btn"
>
Delete
</Button>
</>
}
>
Are you sure you want to delete <strong>{modelName}</strong>? Once deleted,
this action cannot be undone.
</AlertDialog>
);
}
export default DeleteConfirmDialog;

View File

@@ -0,0 +1 @@
export { default } from './DeleteConfirmDialog';

View File

@@ -0,0 +1,58 @@
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
.fullWidth {
width: 100%;
}
.required {
composes: required from './shared.module.scss';
}
.modelCostDrawer {
// Uniform horizontal padding across header / body / footer. The header and
// footer read these dialog vars; the body (rendered in drawer-description)
// is set directly below.
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
display: flex;
overflow-y: auto;
// The drawer body — children render inside [data-slot='drawer-description']
// (this is the @signozhq drawer, not antd, so .ant-drawer-body was a no-op).
[data-slot='drawer-description'] {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
padding: var(--spacing-10) var(--spacing-12);
}
[data-slot='select-content'] {
width: var(--radix-select-trigger-width);
}
}
.title {
h3 {
margin: 0;
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-semibold);
}
p {
margin: var(--spacing-2) 0 0;
color: var(--l3-foreground);
font-size: 12px;
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
// Horizontal padding is provided by the drawer-footer slot var above.
padding: 0;
width: 100%;
}

View File

@@ -0,0 +1,238 @@
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Controller, useForm } from 'react-hook-form';
import PatternEditor from './components/PatternEditor';
import PricingFields from './components/PricingFields';
import SourceSelector from './components/SourceSelector';
import { PROVIDER_OPTIONS } from '../../../constants';
import styles from './ModelCostDrawer.module.scss';
import {
validateModelName,
validatePricing,
validateProvider,
} from '../../../utils';
import type { DrawerDraft, DrawerMode } from '../../../types';
interface ModelCostDrawerProps {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
onClose: () => void;
onSave: (draft: DrawerDraft) => void;
isSaving: boolean;
saveError: string | null;
canManage: boolean;
}
function ModelCostDrawer({
isOpen,
mode,
initialDraft,
onClose,
onSave,
isSaving,
saveError,
canManage,
}: ModelCostDrawerProps): JSX.Element {
// Default mode validates on submit, then re-validates on change — so we don't
// flag empty fields before the user has tried to save, but errors clear live
// once they start fixing them.
const {
control,
handleSubmit,
watch,
formState: { isDirty },
} = useForm<DrawerDraft>({
defaultValues: initialDraft,
});
const isOverride = watch('isOverride');
// Metadata (model id / provider / patterns / source) is editable by any
// manager. Pricing fields are editable only once the user picks "User
// override" — auto-populated pricing is managed by SigNoz. Write APIs are
// Admin-only, so non-managers can't edit anything.
const metadataReadOnly = !canManage;
const pricingReadOnly = !canManage || !isOverride;
// Non-managers can only view (write APIs are Admin-only), so the drawer is a
// read-only "View" rather than "Edit"/"Add".
let drawerTitle = 'Add model cost';
if (!canManage) {
drawerTitle = 'View model cost';
} else if (mode === 'edit') {
drawerTitle = 'Edit model cost';
}
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onClose}
testId="drawer-cancel-btn"
>
{canManage ? 'Cancel' : 'Close'}
</Button>
{canManage && (
<Button
variant="solid"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!isDirty}
loading={isSaving}
testId="drawer-save-btn"
>
Save
</Button>
)}
</div>
);
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
width="base"
className={styles.modelCostDrawer}
footer={footer}
title={drawerTitle}
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id">
Billing model ID{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Controller
name="modelName"
control={control}
rules={{
validate: (value): true | string => validateModelName(value, mode),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<Input
id="billing-model-id"
placeholder="e.g. openai:gpt-4o"
required
value={field.value}
disabled={mode === 'edit' || metadataReadOnly}
aria-invalid={!!fieldState.error}
onChange={(e): void => field.onChange(e.target.value)}
testId="drawer-model-id-input"
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select">Provider</label>
<Controller
name="provider"
control={control}
rules={{ validate: validateProvider }}
render={({ field, fieldState }): JSX.Element => (
<>
<SelectSimple
id="provider-select"
value={field.value}
onChange={(value): void => field.onChange(value as string)}
items={PROVIDER_OPTIONS}
disabled={mode === 'edit' || metadataReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-provider-select"
/>
{fieldState.error && (
<Typography.Text size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
</div>
<Controller
name="patterns"
control={control}
render={({ field }): JSX.Element => (
<PatternEditor
patterns={field.value}
isReadOnly={metadataReadOnly}
onChange={field.onChange}
/>
)}
/>
{/* Source is auto vs. override — a choice only a manager can make, so
there's nothing to show a read-only viewer. */}
{canManage && (
<Controller
name="isOverride"
control={control}
// Pricing requirements depend on this toggle, so re-validate pricing
// whenever the source changes (clears/sets the pricing error).
rules={{ deps: ['pricing'] }}
render={({ field }): JSX.Element => (
<SourceSelector
isOverride={field.value}
isReadOnly={metadataReadOnly}
disableAuto={mode === 'add' || !initialDraft.sourceId}
onChange={field.onChange}
/>
)}
/>
)}
<Controller
name="pricing"
control={control}
rules={{
validate: (value, values): true | string =>
validatePricing(value, values.isOverride),
}}
render={({ field, fieldState }): JSX.Element => (
<>
<PricingFields
pricing={field.value}
isReadOnly={pricingReadOnly}
onChange={(patch): void => field.onChange({ ...field.value, ...patch })}
/>
{fieldState.error && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{fieldState.error.message}
</Typography.Text>
)}
</>
)}
/>
{saveError && (
<Typography.Text as="p" size="small" color="danger" role="alert">
{saveError}
</Typography.Text>
)}
</DrawerWrapper>
);
}
export default ModelCostDrawer;

View File

@@ -0,0 +1,69 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.fullWidth {
width: 100%;
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.cacheModeField {
margin-top: var(--spacing-5);
}
.extraBucketsSection {
margin-top: var(--spacing-7);
gap: var(--spacing-5);
}
.extraBucketsSectionHead {
display: flex;
align-items: center;
justify-content: space-between;
}
.bucketRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
input {
flex: 1 auto auto;
min-width: 0;
}
}
.bucketRowName {
flex: 0 0 110px;
}
.bucketAddBtn {
width: 100%;
}
.bucketPicker {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.bucketPickerTitle {
font-size: var(--periscope-font-size-small);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.bucketPickerChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,179 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import { Plus, Trash2 } from '@signozhq/icons';
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { CACHE_BUCKETS, CACHE_MODE_OPTIONS } from '../../../../../constants';
import styles from './ExtraPricingBuckets.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { CacheBucketKey, DrawerDraft } from '../../../../../types';
import { Tooltip } from 'antd';
type Pricing = DrawerDraft['pricing'];
interface ExtraPricingBucketsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function ExtraPricingBuckets({
pricing,
isReadOnly,
onChange,
}: ExtraPricingBucketsProps): JSX.Element {
const [isExtraPricingBucketOpen, setIsExtraPricingBucketOpen] =
useState<boolean>(false);
// Track which buckets are shown separately from their value, so a freshly
// added bucket can start blank (value null) instead of being seeded to 0.
// Seeded from buckets that already carry a value (edit mode).
const [addedKeys, setAddedKeys] = useState<Set<CacheBucketKey>>(
() =>
new Set(
CACHE_BUCKETS.filter((b) => pricing[b.key] !== null).map((b) => b.key),
),
);
const addedBuckets = CACHE_BUCKETS.filter((b) => addedKeys.has(b.key));
const availableBuckets = CACHE_BUCKETS.filter((b) => !addedKeys.has(b.key));
const patchBucket = (key: CacheBucketKey, value: number | null): void => {
const patch: Partial<Pricing> = { [key]: value };
onChange(patch);
};
const addBucket = (key: CacheBucketKey): void => {
// Leave the value null so the field renders blank until the user types.
setAddedKeys((prev) => new Set(prev).add(key));
// Close the picker once nothing is left to add.
if (availableBuckets.length <= 1) {
setIsExtraPricingBucketOpen(false);
}
};
const removeBucket = (key: CacheBucketKey): void => {
setAddedKeys((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
patchBucket(key, null);
};
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<Typography.Text as="span" size="small" color="muted">
Extra pricing buckets
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
optional
</Typography.Text>
</div>
{addedBuckets.map((bucket) => (
<div className={styles.bucketRow} key={bucket.key}>
<Typography.Text as="span" className={styles.bucketRowName}>
{bucket.label}
</Typography.Text>
<Input
type="number"
min={0}
step={0.01}
value={pricing[bucket.key] ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
// Clearing the field is allowed — the row stays mounted because
// presence is tracked in `addedKeys`, not the value. Removal is
// explicit via the trash button.
patchBucket(bucket.key, parsePricingAmount(e.target.value))
}
testId={`drawer-${bucket.testId}-cost`}
/>
<Tooltip title="Pricing per 1M tokens" placement="left">
<Typography.Text size="xs" color="muted">
1M
</Typography.Text>
</Tooltip>
{!isReadOnly && (
<Button
size="icon"
variant="ghost"
color="destructive"
onClick={(): void => removeBucket(bucket.key)}
aria-label={`Remove ${bucket.label}`}
data-testid={`drawer-remove-${bucket.testId}`}
prefix={<Trash2 size={14} />}
/>
)}
</div>
))}
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}
items={CACHE_MODE_OPTIONS}
onChange={(v): void => onChange({ cacheMode: v as CacheModeDTO })}
disabled={isReadOnly}
className={styles.fullWidth}
withPortal={false}
testId="drawer-cache-mode"
/>
</div>
)}
{!isReadOnly && !isExtraPricingBucketOpen && availableBuckets.length > 0 && (
<Button
variant="dashed"
color="secondary"
className={styles.bucketAddBtn}
prefix={<Plus size={14} />}
onClick={(): void => setIsExtraPricingBucketOpen(true)}
testId="drawer-add-bucket-btn"
>
Add pricing bucket
</Button>
)}
{!isReadOnly && isExtraPricingBucketOpen && (
<div className={styles.bucketPicker} data-testid="drawer-bucket-picker">
<div className={styles.bucketPickerTitle}>Add a pricing bucket</div>
<div className={styles.bucketPickerChips}>
{availableBuckets.map((bucket) => (
<Button
key={bucket.key}
variant="outlined"
color="secondary"
size="sm"
prefix={<Plus size={12} />}
onClick={(): void => addBucket(bucket.key)}
testId={`drawer-add-bucket-${bucket.testId}`}
>
{bucket.label}
</Button>
))}
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
onClick={(): void => setIsExtraPricingBucketOpen(false)}
testId="drawer-add-bucket-cancel"
>
Cancel
</Button>
</div>
)}
</div>
);
}
export default ExtraPricingBuckets;

View File

@@ -0,0 +1,49 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.help {
composes: help from '../../shared.module.scss';
}
.patternBox {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.patternChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
min-height: 28px;
}
.patternChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.patternChipRemove {
background: transparent;
border: none;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
&:hover {
color: var(--accent-cherry);
}
}
.patternAdd {
display: flex;
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import styles from './PatternEditor.module.scss';
interface PatternEditorProps {
patterns: string[];
isReadOnly: boolean;
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
onChange,
}: PatternEditorProps): JSX.Element {
const [patternInput, setPatternInput] = useState<string>('');
const addPattern = (): void => {
const next = patternInput.trim();
if (!next || patterns.includes(next)) {
setPatternInput('');
return;
}
onChange([...patterns, next]);
setPatternInput('');
};
const removePattern = (pattern: string): void => {
onChange(patterns.filter((p) => p !== pattern));
};
return (
<div className={styles.drawerSection}>
<Typography.Text as="span">
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</Typography.Text>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (
<Badge
key={pattern}
color="vanilla"
variant="outline"
className={styles.patternChip}
>
{pattern}*
{!isReadOnly && (
<button
type="button"
aria-label={`Remove pattern ${pattern}`}
className={styles.patternChipRemove}
onClick={(): void => removePattern(pattern)}
>
<X size={10} />
</button>
)}
</Badge>
))}
</div>
{!isReadOnly && (
<div className={styles.patternAdd}>
<Input
placeholder="Add pattern…"
value={patternInput}
onChange={(e): void => setPatternInput(e.target.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
e.preventDefault();
addPattern();
}
}}
testId="drawer-pattern-input"
/>
<Button
variant="outlined"
color="secondary"
onClick={addPattern}
testId="drawer-pattern-add-btn"
>
+ Add
</Button>
</div>
)}
</div>
<Typography.Text as="p" size="small" color="muted">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
</Typography.Text>
</div>
);
}
export default PatternEditor;

View File

@@ -0,0 +1,31 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.pricingField {
composes: pricingField from '../../shared.module.scss';
}
.required {
composes: required from '../../shared.module.scss';
}
.pricingGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
}

View File

@@ -0,0 +1,91 @@
import { Input } from '@signozhq/ui/input';
import { Lock } from '@signozhq/icons';
import cx from 'classnames';
import ExtraPricingBuckets from '../ExtraPricingBuckets';
import styles from './PricingFields.module.scss';
import { parsePricingAmount } from '../../../../../utils';
import type { DrawerDraft } from '../../../../../types';
import { Typography } from '@signozhq/ui/typography';
type Pricing = DrawerDraft['pricing'];
interface PricingFieldsProps {
pricing: Pricing;
isReadOnly: boolean;
onChange: (patch: Partial<Pricing>) => void;
}
function PricingFields({
pricing,
isReadOnly,
onChange,
}: PricingFieldsProps): JSX.Element {
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
<Lock size={12} />
<Typography.Text color="muted">Read-only</Typography.Text>
</span>
)}
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost">
Input cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="input-cost"
type="number"
step={0.01}
required
value={pricing.input ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ input: parsePricingAmount(e.target.value) })
}
testId="drawer-input-cost"
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost">
Output cost{' '}
<span className={styles.required} aria-hidden="true">
*
</span>
</label>
<Input
id="output-cost"
type="number"
step={0.01}
required
value={pricing.output ?? ''}
disabled={isReadOnly}
onChange={(e): void =>
onChange({ output: parsePricingAmount(e.target.value) })
}
testId="drawer-output-cost"
/>
</div>
</div>
<ExtraPricingBuckets
pricing={pricing}
isReadOnly={isReadOnly}
onChange={onChange}
/>
</div>
);
}
export default PricingFields;

View File

@@ -0,0 +1,115 @@
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}
.drawerSurface {
composes: drawerSurface from '../../shared.module.scss';
}
.drawerSurfaceHead {
composes: drawerSurfaceHead from '../../shared.module.scss';
}
.managedLabel {
composes: managedLabel from '../../shared.module.scss';
}
.sourceRadioGroup {
--radio-group-item-border-color: var(--l2-border);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
width: 100%;
.sourceRadio {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-5) var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid transparent;
background: var(--l3-background);
margin: 0;
width: 100%;
// Include padding + border in the 100% width so the card fits inside
// the SOURCE surface instead of overflowing its right edge.
box-sizing: border-box;
cursor: pointer;
transition:
background-color 0.12s ease,
border-color 0.12s ease;
// The radio button itself: keep it fixed-size and aligned with the title
// baseline (margin-top compensates for align-items: flex-start vs the
// title's line-box).
> button[role='radio'] {
flex: 0 0 16px;
width: 16px;
height: 16px;
margin-top: 3px;
}
// The library wraps children in a <label>. Make it grow into the
// remaining width and reset the .drawerSection label typography leak
// (set earlier in this file) so the title/desc divs use their own styles.
> label {
flex: 1 1 auto;
min-width: 0;
display: block;
text-align: left;
cursor: pointer;
font-size: inherit;
font-weight: inherit;
color: inherit;
}
// Radix RadioGroupItem renders <button data-state="checked|unchecked">.
// Use :has() to highlight the wrapper card when its inner button is checked.
&.sourceRadioAuto:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
}
&.sourceRadioOverride:has(button[data-state='checked']) {
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
}
&:hover {
background: var(--l3-background-hover);
}
}
}
.sourceRadioTitle {
font-weight: var(--font-weight-semibold);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.sourceRadioDesc {
margin-top: 2px;
font-size: 12px;
color: var(--l3-foreground);
}
.resetConfirm {
margin-top: var(--spacing-6);
padding: var(--spacing-6);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--accent-primary) 6%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-primary) 20%, transparent);
p {
margin: 0 0 var(--spacing-5);
font-size: 12px;
}
}
.resetConfirmActions {
display: flex;
gap: var(--spacing-4);
justify-content: flex-end;
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
interface SourceSelectorProps {
isOverride: boolean;
isReadOnly: boolean;
disableAuto?: boolean;
onChange: (isOverride: boolean) => void;
}
// Auto-populated vs user-override selector, with a confirm step before
// discarding custom values back to defaults.
function SourceSelector({
isOverride,
isReadOnly,
disableAuto = false,
onChange,
}: SourceSelectorProps): JSX.Element {
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
const handleSourceChange = (value: 'auto' | 'override'): void => {
if (value === 'auto' && isOverride) {
setShowResetConfirm(true);
return;
}
if (value === 'override' && !isOverride) {
onChange(true);
}
};
const confirmReset = (): void => {
onChange(false);
setShowResetConfirm(false);
};
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">
<Lock size={12} />
Managed by SigNoz
</span>
)}
</div>
<RadioGroup
value={isOverride ? 'override' : 'auto'}
onChange={(value): void => handleSourceChange(value as 'auto' | 'override')}
className={styles.sourceRadioGroup}
>
<RadioGroupItem
value="auto"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioAuto)}
testId="drawer-source-auto"
disabled={disableAuto}
>
<div className={styles.sourceRadioTitle}>Auto-populated</div>
<div className={styles.sourceRadioDesc}>
{disableAuto
? 'Available once SigNoz has default pricing for this model.'
: 'Default pricing from SigNoz.'}
</div>
</RadioGroupItem>
<RadioGroupItem
value="override"
containerClassName={cx(styles.sourceRadio, styles.sourceRadioOverride)}
testId="drawer-source-override"
>
<div className={styles.sourceRadioTitle}>User override</div>
<div className={styles.sourceRadioDesc}>
Custom pricing. Takes precedence.
</div>
</RadioGroupItem>
</RadioGroup>
{showResetConfirm && (
<div className={styles.resetConfirm} aria-label="Reset to default pricing">
<p>
Reset to default pricing? Custom values will be discarded. It might take
24 hours for changes to take effect.
</p>
<div className={styles.resetConfirmActions}>
<Button
variant="outlined"
color="secondary"
onClick={(): void => setShowResetConfirm(false)}
testId="drawer-reset-keep-btn"
>
Keep
</Button>
<Button
variant="solid"
color="primary"
onClick={confirmReset}
testId="drawer-reset-confirm-btn"
>
Reset
</Button>
</div>
</div>
)}
</div>
);
}
export default SourceSelector;

View File

@@ -0,0 +1,100 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import { EMPTY_DRAFT } from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
mode: DrawerMode;
initialDraft: DrawerDraft;
openForAdd: (prefillModelName?: string) => void;
openForEdit: (rule: PricingRule) => void;
close: () => void;
save: (draft: DrawerDraft) => Promise<void>;
isSaving: boolean;
saveError: string | null;
selectedRuleId: string | null;
}
export function useModelCostDrawer(): UseModelCostDrawerResult {
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [mode, setMode] = useState<DrawerMode>('add');
const [initialDraft, setInitialDraft] = useState<DrawerDraft>(EMPTY_DRAFT);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
}, [queryClient]);
const openForAdd = useCallback((): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: '',
patterns: [],
});
setSelectedRuleId(null);
setSaveError(null);
setIsOpen(true);
}, []);
const openForEdit = useCallback((rule: PricingRule): void => {
setMode('edit');
setInitialDraft(draftFromRule(rule));
setSelectedRuleId(rule.id);
setSaveError(null);
setIsOpen(true);
}, []);
const close = useCallback((): void => {
setIsOpen(false);
setSelectedRuleId(null);
setSaveError(null);
}, []);
const save = useCallback(
async (draft: DrawerDraft): Promise<void> => {
setSaveError(null);
try {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
} catch (error) {
const message = error instanceof Error ? error.message : 'Save failed';
setSaveError(message);
}
},
[createOrUpdate, invalidateList, mode],
);
return {
isOpen,
mode,
initialDraft,
openForAdd,
openForEdit,
close,
save,
isSaving,
saveError,
selectedRuleId,
};
}

View File

@@ -0,0 +1,2 @@
export { default } from './ModelCostDrawer';
export { useModelCostDrawer } from './hooks/useModelCostDrawer';

View File

@@ -0,0 +1,59 @@
/* Shared drawer selectors used by 2+ of the model-cost drawer components. */
/* Components pull these in via CSS-modules `composes` from their own module so */
/* the authored class names in the TSX stay identical. */
/* NOTE: this file is a `composes` target, so it is parsed as plain CSS (no SCSS */
/* preprocessing). Keep it flat — no nesting, no slash-slash comments. */
.drawerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.drawerSection .help,
.help {
margin: 0;
}
.help code {
padding: 1px var(--spacing-2);
border-radius: 3px;
background: var(--l3-background);
font-size: 10px;
}
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}
.drawerSurfaceHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-5);
}
.managedLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.required {
color: var(--accent-cherry);
}
.pricingField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.pricingField input {
width: 100%;
}

View File

@@ -15,6 +15,6 @@
justify-content: center;
margin-top: var(--spacing-8);
min-height: 400px;
color: var(--text-vanilla-400);
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,66 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
useDeleteLLMPricingRule,
} from 'api/generated/services/llmpricingrules';
import type { PricingRule } from '../../types';
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
// and the model name to show in the confirmation copy.
type PendingDelete = Pick<PricingRule, 'id' | 'modelName'>;
interface UseModelCostDeleteResult {
requestDelete: (rule: PendingDelete) => void;
confirmDelete: () => Promise<void>;
cancelDelete: () => void;
pendingDelete: PendingDelete | null;
isDeleting: boolean;
}
// Owns the confirm-then-delete flow for a pricing rule, independent of the
// add/edit drawer — delete is triggered from the table row menu, so this state
// lives at the panel level rather than inside useModelCostDrawer.
export function useModelCostDelete(): UseModelCostDeleteResult {
const queryClient = useQueryClient();
// The rule queued for deletion. Non-null drives the confirm dialog open.
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const { mutateAsync: deleteRuleApi, isLoading: isDeleting } =
useDeleteLLMPricingRule();
const requestDelete = useCallback((rule: PendingDelete): void => {
setPendingDelete({ id: rule.id, modelName: rule.modelName });
}, []);
const cancelDelete = useCallback((): void => {
setPendingDelete(null);
}, []);
const confirmDelete = useCallback(async (): Promise<void> => {
if (!pendingDelete) {
return;
}
try {
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
setPendingDelete(null);
toast.success('Model cost deleted');
} catch (error) {
const message = error instanceof Error ? error.message : 'Delete failed';
toast.error(message);
}
}, [deleteRuleApi, pendingDelete, queryClient]);
return {
requestDelete,
confirmDelete,
cancelDelete,
pendingDelete,
isDeleting,
};
}

View File

@@ -1,6 +1,68 @@
import { LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO } from 'api/generated/services/sigNoz.schemas';
import type { CacheBucketDef, DrawerDraft } from './types';
export const PAGE_SIZE = 20;
export const PAGE_KEY = 'page';
export const LIMIT_KEY = 'limit';
export const SEARCH_KEY = 'search';
export const SEARCH_DEBOUNCE_MS = 300;
export const SOURCE_KEY = 'source';
export type SourceFilter = 'all' | 'override' | 'auto';
export const SOURCE_FILTER_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: 'all', label: 'All sources' },
{ value: 'override', label: 'User override' },
{ value: 'auto', label: 'Auto' },
];
export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
SourceFilter,
boolean | undefined
> = {
all: undefined,
override: true,
auto: false,
};
// Match the page size so the skeleton reserves the same number of rows the
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
{ value: 'Azure OpenAI', label: 'Azure OpenAI' },
{ value: 'Google', label: 'Google' },
{ value: 'Self-hosted', label: 'Self-hosted' },
{ value: 'Other', label: 'Other' },
];
export const CACHE_MODE_OPTIONS = [
{ value: CacheModeDTO.subtract, label: 'Subtract (OpenAI style)' },
{ value: CacheModeDTO.additive, label: 'Additive (Anthropic style)' },
// https://app.notion.com/p/signoz/LLM-Tokens-Cost-Calculation-330fcc6bcd19805283ccc841d596358e?source=copy_link#33efcc6bcd1980e6a187e442c6ba5996
{ value: CacheModeDTO.unknown, label: 'Unknown' },
];
export const CACHE_BUCKETS: CacheBucketDef[] = [
{ key: 'cacheRead', label: 'cache_read', testId: 'cache-read' },
{ key: 'cacheWrite', label: 'cache_write', testId: 'cache-write' },
];
export const EMPTY_DRAFT: DrawerDraft = {
id: null,
sourceId: null,
modelName: '',
provider: 'OpenAI',
patterns: [],
isOverride: true,
pricing: {
input: null,
output: null,
cacheMode: CacheModeDTO.unknown,
cacheRead: null,
cacheWrite: null,
},
};

View File

@@ -1,4 +1,39 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
}
export type DrawerMode = 'add' | 'edit';
// Optional pricing buckets the user can add/remove. Keyed by the matching
// DrawerDraft['pricing'] field.
export type CacheBucketKey = 'cacheRead' | 'cacheWrite';
export interface CacheBucketDef {
key: CacheBucketKey;
label: string;
testId: string;
}
export interface DrawerDraft {
id: string | null;
sourceId: string | null;
modelName: string;
provider: string;
patterns: string[];
isOverride: boolean;
pricing: {
input: number | null;
output: number | null;
cacheMode: CacheModeDTO;
cacheRead: number | null;
cacheWrite: number | null;
};
}

View File

@@ -1,8 +1,19 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type LlmpricingruletypesLLMPricingCacheCostsDTO,
type LlmpricingruletypesLLMRulePricingDTO,
type LlmpricingruletypesUpdatableLLMPricingRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { ExtraBucket } from './types';
import type { LlmpricingruletypesLLMPricingRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DrawerDraft,
DrawerMode,
ExtraBucket,
PricingRule,
} from './types';
dayjs.extend(relativeTime);
@@ -13,6 +24,19 @@ const getRelativeTime = (
return parsed?.isValid() ? parsed.fromNow() : '—';
};
const hasCacheValue = (value: number | null | undefined): value is number =>
typeof value === 'number' && value > 0;
// ─── Input helpers ───────────────────────────────────────────────────────────
export const parsePricingAmount = (raw: string): number | null => {
if (raw.trim() === '') {
return null;
}
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
// ─── Display helpers ─────────────────────────────────────────────────────────
export const formatPricePerMillion = (value: number | undefined): string => {
@@ -23,38 +47,117 @@ export const formatPricePerMillion = (value: number | undefined): string => {
return `$${value.toFixed(2)}`;
};
export const getExtraBuckets = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): ExtraBucket[] => {
export const getExtraBuckets = (rule: PricingRule): ExtraBucket[] => {
const cache = rule.pricing?.cache;
if (!cache) {
return [];
}
const buckets: ExtraBucket[] = [];
if (typeof cache.read === 'number' && cache.read > 0) {
if (hasCacheValue(cache.read)) {
buckets.push({ key: 'cache_read', pricePerMillion: cache.read });
}
if (typeof cache.write === 'number' && cache.write > 0) {
if (hasCacheValue(cache.write)) {
buckets.push({ key: 'cache_write', pricePerMillion: cache.write });
}
return buckets;
};
export const getSourceLabel = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): 'Auto' | 'User override' => (rule.isOverride ? 'User override' : 'Auto');
export const getSourceLabel = (rule: PricingRule): 'Auto' | 'User override' =>
rule.isOverride ? 'User override' : 'Auto';
export const getRelativeLastSeen = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
export const getRelativeLastSeen = (rule: PricingRule): string =>
getRelativeTime(rule.updatedAt || rule.syncedAt || rule.createdAt);
// Canonical id shown under the model name, e.g. "openai:gpt-4o". Both segments
// are lower-cased so the id is consistently normalised (providers/models can
// arrive with mixed casing).
export const getCanonicalId = (
rule: LlmpricingruletypesLLMPricingRuleDTO,
): string => {
export const getCanonicalId = (rule: PricingRule): string => {
const provider = rule.provider?.trim().toLowerCase() || 'unknown';
const model = rule.modelName?.trim().toLowerCase() || 'unknown';
return `${provider}:${model}`;
};
// ─── Drawer draft <-> API helpers ────────────────────────────────────────────
export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
id: rule.id,
sourceId: rule.sourceId ?? null,
modelName: rule.modelName,
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
cacheMode: rule.pricing?.cache?.mode ?? CacheModeDTO.unknown,
cacheRead: rule.pricing?.cache?.read ?? null,
cacheWrite: rule.pricing?.cache?.write ?? null,
},
});
const buildCacheCosts = (
pricing: DrawerDraft['pricing'],
): LlmpricingruletypesLLMPricingCacheCostsDTO | undefined => {
const { cacheMode, cacheRead, cacheWrite } = pricing;
if (!hasCacheValue(cacheRead) && !hasCacheValue(cacheWrite)) {
return undefined;
}
return {
mode: cacheMode,
...(hasCacheValue(cacheRead) && { read: cacheRead }),
...(hasCacheValue(cacheWrite) && { write: cacheWrite }),
};
};
export const buildPricingPayload = (
draft: DrawerDraft,
): LlmpricingruletypesLLMRulePricingDTO => {
const cache = buildCacheCosts(draft.pricing);
return {
input: draft.pricing.input ?? 0,
output: draft.pricing.output ?? 0,
...(cache && { cache }),
};
};
export const buildRulePayload = (
draft: DrawerDraft,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => ({
id: draft.id || undefined,
sourceId: draft.sourceId || undefined,
modelName: draft.modelName.trim(),
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: true,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
export const validateModelName = (
modelName: string,
mode: DrawerMode,
): true | string =>
mode === 'add' && !modelName.trim() ? 'Billing model ID is required.' : true;
export const validateProvider = (provider: string): true | string =>
provider.trim() ? true : 'Provider is required.';
export const validatePricing = (
pricing: DrawerDraft['pricing'],
isOverride: boolean,
): true | string => {
if (!isOverride) {
return true;
}
if (pricing.input === null || pricing.input <= 0) {
return 'Input cost must be greater than 0.';
}
if (pricing.output === null || pricing.output <= 0) {
return 'Output cost must be greater than 0.';
}
if ((pricing.cacheRead ?? 0) < 0 || (pricing.cacheWrite ?? 0) < 0) {
return 'Cache costs must be non-negative.';
}
return true;
};

View File

@@ -1,9 +1,14 @@
@use '../../styles/scrollbar' as *;
.members-settings-page {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
overflow-y: auto;
@include custom-scrollbar;
}
.members-settings {

View File

@@ -6,7 +6,7 @@ import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import InviteMembersModal from 'container/MembersSettings/components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { parseAsBoolean, useQueryState } from 'nuqs';

View File

@@ -110,7 +110,9 @@ describe('MembersSettings (integration)', () => {
fireEvent.click(await screen.findByText('Alice Smith'));
await screen.findByText('Member Details');
await expect(
screen.findByText('Member Details'),
).resolves.toBeInTheDocument();
});
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
@@ -127,7 +129,7 @@ describe('MembersSettings (integration)', () => {
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
).resolves.toHaveLength(3);
});
@@ -137,7 +139,7 @@ describe('MembersSettings (integration)', () => {
});
await expect(
screen.findAllByPlaceholderText('john@signoz.io'),
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
).resolves.toHaveLength(3);
});
});

View File

@@ -0,0 +1,38 @@
.invite-members-modal {
max-width: 700px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--l1-foreground);
margin: 0;
}
[data-slot='dialog-description'] {
padding: var(--padding-4);
}
}
.invite-members-modal__footer {
padding-top: var(--padding-4);
border-top: 1px solid var(--l1-border);
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-6);
}

View File

@@ -0,0 +1,71 @@
import { useCallback } from 'react';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import './InviteMembersModal.styles.scss';
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
function InviteMembersModal({
open,
onClose,
onComplete,
}: InviteMembersModalProps): JSX.Element {
const handleSuccess = useCallback((): void => {
toast.success('Invites sent successfully', { position: 'top-right' });
onClose();
onComplete?.();
}, [onClose, onComplete]);
const handlePartialSuccess = useCallback((): void => {
toast.warning('Some invites failed', { position: 'top-right' });
onComplete?.();
}, [onComplete]);
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
>
<InviteMembers
onSuccess={handleSuccess}
onPartialSuccess={handlePartialSuccess}
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
<div className="invite-members-modal__footer">
<Button type="button" variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
onClick={submit}
disabled={!canSubmit}
loading={isSubmitting}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
)}
/>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -0,0 +1,210 @@
import { toast } from '@signozhq/ui/sonner';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteMembersModal from '../InviteMembersModal';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
warning: jest.fn(),
},
}));
interface MockInviteMembersProps {
onSuccess: () => void;
onPartialSuccess: () => void;
onAllFailed?: () => void;
renderFooter: (props: {
submit: () => void;
canSubmit: boolean;
isSubmitting: boolean;
}) => JSX.Element;
}
let mockInviteMembersProps: MockInviteMembersProps | null = null;
jest.mock('components/InviteMembers/InviteMembers', () => {
return function MockInviteMembers(props: MockInviteMembersProps): JSX.Element {
mockInviteMembersProps = props;
return (
<div data-testid="mock-invite-members">
{props.renderFooter({
submit: jest.fn(),
canSubmit: true,
isSubmitting: false,
})}
</div>
);
};
});
const defaultProps = {
open: true,
onClose: jest.fn(),
onComplete: jest.fn(),
};
function renderComponent(
props: Partial<typeof defaultProps> = {},
): ReturnType<typeof render> {
return render(<InviteMembersModal {...defaultProps} {...props} />);
}
describe('InviteMembersModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInviteMembersProps = null;
});
describe('rendering', () => {
it('renders modal with title and InviteMembers component', () => {
renderComponent();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
'Invite Team Members',
);
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
});
it('does not render when open=false', () => {
renderComponent({ open: false });
expect(screen.queryByText('Invite Team Members')).not.toBeInTheDocument();
});
});
describe('footer buttons', () => {
it('renders Cancel and Invite buttons', () => {
renderComponent();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /invite team members/i }),
).toBeInTheDocument();
});
it('disables Invite button when canSubmit=false', () => {
const { unmount } = renderComponent();
unmount();
const { getByRole } = render(
mockInviteMembersProps?.renderFooter({
submit: jest.fn(),
canSubmit: false,
isSubmitting: false,
}) as JSX.Element,
);
expect(getByRole('button', { name: /invite team members/i })).toBeDisabled();
});
it('shows loading state when isSubmitting=true', () => {
const { unmount } = renderComponent();
unmount();
const { getByRole } = render(
mockInviteMembersProps?.renderFooter({
submit: jest.fn(),
canSubmit: true,
isSubmitting: true,
}) as JSX.Element,
);
expect(getByRole('button', { name: /inviting/i })).toBeInTheDocument();
});
it('calls onClose when Cancel is clicked', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ onClose });
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls submit when Invite button is clicked', async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
const { unmount } = renderComponent();
unmount();
const { getByRole } = render(
mockInviteMembersProps?.renderFooter({
submit: mockSubmit,
canSubmit: true,
isSubmitting: false,
}) as JSX.Element,
);
await user.click(getByRole('button', { name: /invite team members/i }));
expect(mockSubmit).toHaveBeenCalledTimes(1);
});
});
describe('handleSuccess callback', () => {
it('shows success toast, calls onClose and onComplete', () => {
const onClose = jest.fn();
const onComplete = jest.fn();
renderComponent({ onClose, onComplete });
mockInviteMembersProps?.onSuccess();
expect(toast.success).toHaveBeenCalledWith('Invites sent successfully', {
position: 'top-right',
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('works without onComplete prop', () => {
const onClose = jest.fn();
renderComponent({ onClose, onComplete: undefined });
mockInviteMembersProps?.onSuccess();
expect(toast.success).toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('handlePartialSuccess callback', () => {
it('shows warning toast and calls onComplete', () => {
const onComplete = jest.fn();
renderComponent({ onComplete });
mockInviteMembersProps?.onPartialSuccess();
expect(toast.warning).toHaveBeenCalledWith('Some invites failed', {
position: 'top-right',
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('does not call onClose on partial success', () => {
const onClose = jest.fn();
renderComponent({ onClose });
mockInviteMembersProps?.onPartialSuccess();
expect(onClose).not.toHaveBeenCalled();
});
});
describe('dialog close behavior', () => {
it('calls onClose when dialog is closed via close button', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderComponent({ onClose });
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(onClose).toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,5 @@
import { Gauge } from '@signozhq/icons';
import { Tooltip } from 'antd';
import { MetricreductionruletypesGettableReductionRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { Badge } from '@signozhq/ui/badge';
@@ -8,16 +9,26 @@ interface VolumeControlBadgeProps {
}
function VolumeControlBadge({ rule }: VolumeControlBadgeProps): JSX.Element {
return (
const badge = (
<Badge
data-testid="vc-badge-active"
variant="outline"
color={!rule.active ? 'success' : 'warning'}
color={rule.active ? 'success' : 'warning'}
>
<Gauge size={12} />
{!rule.active ? 'Active' : 'Pending'}
{rule.active ? 'Active' : 'Pending'}
</Badge>
);
if (rule.active) {
return badge;
}
return (
<Tooltip title="Takes about 5 minutes to take effect">
<span>{badge}</span>
</Tooltip>
);
}
export default VolumeControlBadge;

View File

@@ -7,6 +7,12 @@
padding: 12px 16px 0 16px;
}
.chartHeader {
display: flex;
flex-direction: column;
gap: 2px;
}
.chartTitle {
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -15,3 +21,11 @@
.chartBody {
height: 340px;
}
.chartStatus {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}

View File

@@ -1,5 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { useGetMetricReductionRuleTimeseries } from 'api/generated/services/metrics';
import { PANEL_TYPES } from 'constants/queryBuilder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
@@ -25,7 +26,9 @@ interface VolumeControlChartProps {
}
function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
const { data } = useGetMetricReductionRuleTimeseries({ query: { enabled } });
const { data, isLoading, isError } = useGetMetricReductionRuleTimeseries({
query: { enabled },
});
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
@@ -65,11 +68,34 @@ function VolumeControlChart({ enabled }: VolumeControlChartProps): JSX.Element {
return (
<div className={styles.chart} data-testid="volume-control-chart">
<Typography.Text className={styles.chartTitle} size={'small'}>
Series volume over time · ingested vs retained
</Typography.Text>
<div className={styles.chartHeader}>
<Typography.Text className={styles.chartTitle} size={'small'}>
Sample volume · ingested vs retained
</Typography.Text>
<Typography.Text size="small" color="muted">
Last 6 hours
</Typography.Text>
</div>
<div className={styles.chartBody} ref={graphRef}>
{dimensions.width > 0 && (
{isLoading && (
<div
className={styles.chartStatus}
data-testid="volume-control-chart-loading"
>
<Spin size="small" />
</div>
)}
{!isLoading && isError && (
<div
className={styles.chartStatus}
data-testid="volume-control-chart-error"
>
<Typography.Text size="small" color="danger">
Failed to load chart
</Typography.Text>
</div>
)}
{!isLoading && !isError && dimensions.width > 0 && (
<BarChart
config={config}
data={chartData}

View File

@@ -17,11 +17,27 @@
gap: 5px;
}
.meterLabelRow {
display: flex;
align-items: center;
gap: 4px;
}
.meterLabel {
text-transform: uppercase;
letter-spacing: 0.04em;
}
.meterInfo {
flex-shrink: 0;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1;
cursor: help;
}
.meterValue {
display: flex;
align-items: baseline;
gap: 6px;
font-family: 'Geist Mono', monospace;
}

View File

@@ -1,6 +1,8 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Spin } from 'antd';
import { Spin, Tooltip } from 'antd';
import { MetricreductionruletypesGettableReductionRulePreviewDTO } from 'api/generated/services/sigNoz.schemas';
import { popupContainer } from 'utils/selectPopupContainer';
import { formatCompact } from '../../../configUtils';
import { RuleMode } from '../../../types';
@@ -27,6 +29,7 @@ function ImpactPanel({
);
}
const full = preview?.ingestedSeries ?? 0;
const current = preview?.currentRetainedSeries ?? 0;
const proposed = preview?.retainedSeries ?? 0;
const deltaPct = current > 0 ? (1 - proposed / current) * 100 : 0;
@@ -40,31 +43,59 @@ function ImpactPanel({
{!isLoading && preview && (
<div className={styles.meterGrid}>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current series
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Full series
</Typography.Text>
<Tooltip
title="Total number of series for this metric before any reduction."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(full)}
</Typography.Text>
</div>
<div className={styles.meter}>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Current retained
</Typography.Text>
<Tooltip
title="Series kept today under the metric's existing rule, or all of them if it has no rule yet."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(current)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Proposed series
</Typography.Text>
<div className={styles.meterLabelRow}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Potential retained
</Typography.Text>
<Tooltip
title="Series that would be kept if you save this rule, with the reduction vs what's retained today."
getPopupContainer={popupContainer}
>
<Info size={12} className={styles.meterInfo} />
</Tooltip>
</div>
<Typography.Text size="2xl" className={styles.meterValue}>
{formatCompact(proposed)}
</Typography.Text>
</div>
<div className={styles.meter}>
<Typography.Text size="xs" color="muted" className={styles.meterLabel}>
Reduction
</Typography.Text>
<Typography.Text
size="2xl"
color={deltaPct >= 0 ? 'success' : undefined}
className={styles.meterValue}
>
{reductionLabel}
{deltaPct !== 0 && (
<Typography.Text
size="small"
color={deltaPct >= 0 ? 'success' : undefined}
>
{reductionLabel}
</Typography.Text>
)}
</Typography.Text>
</div>
</div>

View File

@@ -18,12 +18,12 @@ const MODE_OPTIONS: ModeOption[] = [
},
{
mode: 'include',
title: 'Include attributes',
title: 'Include',
description: 'Allowlist: only the selected attributes stay queryable.',
},
{
mode: 'exclude',
title: 'Exclude attributes',
title: 'Exclude',
description: 'Blocklist: the selected attributes are aggregated away.',
},
];

View File

@@ -53,12 +53,12 @@ function RelatedAssetsWarning({
<div className={styles.warning} data-testid="volume-control-warning">
<Info size={14} />
<div className={styles.warningBody}>
<Typography.Text as="div" size="small" weight="semibold" color="warning">
<Typography.Text as="div" size="base" weight="semibold" color="warning">
This rule affects {impacted.length} related asset
{impacted.length > 1 ? 's' : ''}.
</Typography.Text>
{impactedLabels.length > 0 && (
<Typography.Text as="div" size="sm" color="muted">
<Typography.Text as="div" size="base" color="muted">
{impactedLabels.join(', ')} will no longer be queryable; affected panels
fall back to aggregated data once the rule applies.
</Typography.Text>
@@ -73,7 +73,7 @@ function RelatedAssetsWarning({
<li key={`${asset.type}-${asset.id}-${asset.widget?.id ?? ''}`}>
{href ? (
<Typography.Link
size="sm"
size="base"
href={href}
target="_blank"
rel="noopener noreferrer"
@@ -81,7 +81,7 @@ function RelatedAssetsWarning({
{label}
</Typography.Link>
) : (
<Typography.Text size="sm" color="muted">
<Typography.Text size="base" color="muted">
{label}
</Typography.Text>
)}

View File

@@ -42,6 +42,10 @@ function VolumeControlConfigDrawer({
const footer = (
<div className={styles.footer}>
<Typography.Text size="small" color="muted">
Changes take effect about 5 minutes after saving.
</Typography.Text>
<div className={styles.footerSpacer} />
<Button
variant="outlined"
color="secondary"
@@ -50,7 +54,6 @@ function VolumeControlConfigDrawer({
>
Cancel
</Button>
<div className={styles.footerSpacer} />
{hasExistingRule && (
<Button
variant="ghost"

View File

@@ -8,8 +8,8 @@ function PendingActivationBanner(): JSX.Element {
<div className={styles.banner} data-testid="volume-control-pending-banner">
<Info size={13} />
<Typography.Text size="sm" color="muted">
This metric&apos;s configuration was recently updated. Volume changes will
take effect within a few minutes.
This metric&apos;s configuration was recently updated. Volume changes take
effect within about 5 minutes.
</Typography.Text>
</div>
);

View File

@@ -22,7 +22,7 @@ function VolumeControlSection({
useVolumeControlFeatureGate();
const [isConfigOpen, setIsConfigOpen] = useState(false);
const { data, isLoading, error } = useListMetricReductionRules(
const { data, isLoading, isError } = useListMetricReductionRules(
{ metricName },
{
query: {
@@ -37,7 +37,7 @@ function VolumeControlSection({
}
const rule = data?.data.rules?.[0];
const hasRule = !!rule && !error;
const hasRule = !!rule && !isError;
const openConfig = (): void => setIsConfigOpen(true);
const closeConfig = (): void => setIsConfigOpen(false);
@@ -53,6 +53,16 @@ function VolumeControlSection({
{isLoading && <Skeleton active title={false} paragraph={{ rows: 2 }} />}
{!isLoading && isError && (
<Typography.Text
size="small"
color="danger"
data-testid="volume-control-section-error"
>
Failed to load volume control. Please try again.
</Typography.Text>
)}
{!isLoading && hasRule && rule && !rule.active && (
<PendingActivationBanner />
)}
@@ -65,7 +75,7 @@ function VolumeControlSection({
/>
)}
{!isLoading && !hasRule && (
{!isLoading && !isError && !hasRule && (
<NoRuleEmptyState canManage={canManageVolumeControl} onSetup={openConfig} />
)}

View File

@@ -1,3 +1,9 @@
.statsSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.stats {
display: flex;
flex-wrap: wrap;
@@ -21,11 +27,24 @@
background: var(--callout-success-background);
}
.statCardLabelRow {
display: flex;
align-items: center;
gap: 4px;
}
.statCardLabel {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.statCardInfo {
flex-shrink: 0;
color: var(--bg-vanilla-400, #c0c1c3);
line-height: 1;
cursor: help;
}
.statCardValue {
display: flex;
align-items: baseline;

View File

@@ -1,4 +1,6 @@
import { Info } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton, Tooltip } from 'antd';
import cx from 'classnames';
import { formatCompact, formatUsd } from '../../../configUtils';
@@ -8,12 +10,17 @@ interface VolumeControlStatsProps {
activeRules: number;
ingestedSeries: number;
retainedSeries: number;
ingestedSamples: number;
retainedSamples: number;
estimatedMonthlySavingsUsd: number;
isLoading?: boolean;
isError?: boolean;
}
interface StatItem {
label: string;
value: string;
tooltip: string;
delta?: string;
unit?: string;
highlighted?: boolean;
@@ -24,20 +31,53 @@ function VolumeControlStats({
activeRules,
ingestedSeries,
retainedSeries,
ingestedSamples,
retainedSamples,
estimatedMonthlySavingsUsd,
isLoading = false,
isError = false,
}: VolumeControlStatsProps): JSX.Element {
const overallReduction =
ingestedSeries > 0
? Math.round((1 - retainedSeries / ingestedSeries) * 100)
: 0;
const sampleReduction =
ingestedSamples > 0
? Math.round((1 - retainedSamples / ingestedSamples) * 100)
: 0;
const items: StatItem[] = [
{ label: 'Active rules', value: String(activeRules) },
{ label: 'Ingested series', value: formatCompact(ingestedSeries) },
{
label: 'Configured rules',
value: String(activeRules),
tooltip: 'Volume-control rules currently configured for this workspace.',
},
{
label: 'Ingested series',
value: formatCompact(ingestedSeries),
tooltip:
'Distinct time series across all metrics in the last 1 hour, before any reduction.',
},
{
label: 'Retained series',
value: formatCompact(retainedSeries),
delta: overallReduction > 0 ? `${overallReduction}%` : undefined,
tooltip:
'Distinct time series kept across all metrics in the last 1 hour; everything except what the rules reduce away. Lower than ingested means more reduction.',
},
{
label: 'Ingested samples',
value: formatCompact(ingestedSamples),
tooltip:
'Sample data points across all metrics in the last 1 hour, before any reduction.',
},
{
label: 'Retained samples',
value: formatCompact(retainedSamples),
delta: sampleReduction > 0 ? `${sampleReduction}%` : undefined,
tooltip:
'Sample data points kept across all metrics in the last 1 hour; everything except what the rules reduce. Samples reduce more than series because series do not all carry the same sample volume.',
},
{
label: 'Est. monthly savings',
@@ -45,42 +85,73 @@ function VolumeControlStats({
unit: '/mo',
highlighted: true,
valueGood: true,
tooltip:
'Rough monthly estimate: the samples the rules reduced in the last 1 hour, scaled to a month at 1-month standard retention. It is extrapolated from a single rolling hour.',
},
];
return (
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
>
<Typography.Text size="sm" color="muted" className={styles.statCardLabel}>
{item.label}
</Typography.Text>
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
<div className={styles.statsSection}>
<Typography.Text size="small" color="muted">
Last 1 hour
</Typography.Text>
<div className={styles.stats} data-testid="volume-control-stats">
{items.map((item) => (
<div
key={item.label}
className={cx(styles.statCard, {
[styles.statCardHighlighted]: item.highlighted,
})}
>
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
<div className={styles.statCardLabelRow}>
<Typography.Text
size="sm"
color="muted"
className={styles.statCardLabel}
>
{item.label}
</Typography.Text>
<Tooltip title={item.tooltip}>
<Info size={12} className={styles.statCardInfo} />
</Tooltip>
</div>
{isLoading && (
<Skeleton.Button active size="small" className={styles.statCardValue} />
)}
{!isLoading && isError && (
<Typography.Text
as="div"
size="small"
color="danger"
className={styles.statCardValue}
>
Failed to load
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
{!isLoading && !isError && (
<Typography.Text
as="div"
size="large"
weight="semibold"
color={item.valueGood ? 'success' : undefined}
className={styles.statCardValue}
>
{item.value}
{item.delta && (
<Typography.Text size="small" weight="semibold" color="success">
{item.delta}
</Typography.Text>
)}
{item.unit && (
<Typography.Text size="small" weight="medium" color="muted">
{item.unit}
</Typography.Text>
)}
</Typography.Text>
)}
</Typography.Text>
</div>
))}
</div>
))}
</div>
</div>
);
}

View File

@@ -13,8 +13,10 @@
font-family: 'Geist Mono', monospace;
}
.reductionCell {
font-family: 'Geist Mono', monospace;
.volumeCell {
display: flex;
flex-direction: column;
gap: 2px;
}
.empty {

View File

@@ -36,7 +36,7 @@ type VolumeControlTableParams = Required<
>;
const DEFAULT_PARAMS: VolumeControlTableParams = {
orderBy: OrderBy.reduction,
orderBy: OrderBy.ingested_volume,
order: SortOrder.desc,
search: '',
offset: 0,
@@ -60,11 +60,20 @@ function VolumeControlTab(): JSX.Element {
);
}, [debouncedSearch]);
const { data, isLoading } = useListMetricReductionRules(params, {
const {
data,
isLoading,
isError: isListError,
} = useListMetricReductionRules(params, {
query: { enabled: isVolumeControlEnabled },
});
const { data: statsData } = useGetMetricReductionRuleStats({
const {
data: statsData,
isLoading: isStatsLoading,
isFetching: isStatsFetching,
isError: isStatsError,
} = useGetMetricReductionRuleStats({
query: { enabled: isVolumeControlEnabled },
});
const stats = statsData?.data;
@@ -111,7 +120,7 @@ function VolumeControlTab(): JSX.Element {
{
title: 'MODE',
key: 'mode',
width: 160,
width: 110,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
@@ -138,7 +147,14 @@ function VolumeControlTab(): JSX.Element {
),
},
{
title: 'INGESTED',
title: (
<>
INGESTED{' '}
<Typography.Text size="small" color="muted">
(1h)
</Typography.Text>
</>
),
key: OrderBy.ingested_volume,
width: 130,
sorter: true,
@@ -147,13 +163,28 @@ function VolumeControlTab(): JSX.Element {
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSeries)}
</Typography.Text>
<div className={styles.volumeCell}>
<Typography.Text size="small">
{formatCompact(rule.ingestedSeries)}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{formatCompact(rule.ingestedSamples)} samples
</Typography.Text>
</div>
),
},
{
title: 'RETAINED',
title: (
<>
RETAINED{' '}
<Typography.Text size="small" color="muted">
(1h)
</Typography.Text>
</>
),
key: OrderBy.reduced_volume,
width: 130,
sorter: true,
@@ -162,22 +193,35 @@ function VolumeControlTab(): JSX.Element {
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => (
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}
</Typography.Text>
<div className={styles.volumeCell}>
<Typography.Text size="small">
{formatCompact(rule.retainedSeries)}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{formatCompact(rule.retainedSamples)} samples
</Typography.Text>
</div>
),
},
{
title: 'CHANGE',
key: OrderBy.reduction,
width: 110,
sorter: true,
sortOrder: sortOrderFor(OrderBy.reduction),
width: 140,
render: (
_value: unknown,
rule: MetricreductionruletypesGettableReductionRuleDTO,
): JSX.Element => {
if (rule.reductionPercent <= 0) {
const seriesReduction =
rule.ingestedSeries > 0
? (1 - rule.retainedSeries / rule.ingestedSeries) * 100
: 0;
const samplesReduction =
rule.ingestedSamples > 0
? (1 - rule.retainedSamples / rule.ingestedSamples) * 100
: 0;
if (seriesReduction <= 0 && samplesReduction <= 0) {
return (
<Typography.Text size="small" color="muted">
@@ -185,14 +229,18 @@ function VolumeControlTab(): JSX.Element {
);
}
return (
<Typography.Text
size="small"
weight="semibold"
color="success"
className={styles.reductionCell}
>
{Math.round(rule.reductionPercent)}%
</Typography.Text>
<div className={styles.volumeCell}>
<Typography.Text size="small" weight="semibold" color="success">
{seriesReduction > 0 ? `${Math.round(seriesReduction)}%` : '0%'}{' '}
<Typography.Text size="small" color="muted">
series
</Typography.Text>
</Typography.Text>
<Typography.Text size="small" color="muted">
{samplesReduction > 0 ? `${Math.round(samplesReduction)}%` : '0%'}{' '}
samples
</Typography.Text>
</div>
);
},
},
@@ -273,7 +321,11 @@ function VolumeControlTab(): JSX.Element {
activeRules={total}
ingestedSeries={stats?.ingestedSeries ?? 0}
retainedSeries={stats?.retainedSeries ?? 0}
ingestedSamples={stats?.ingestedSamples ?? 0}
retainedSamples={stats?.retainedSamples ?? 0}
estimatedMonthlySavingsUsd={stats?.estimatedMonthlySavingsUsd ?? 0}
isLoading={isStatsLoading || isStatsFetching}
isError={isStatsError}
/>
<VolumeControlChart enabled={isVolumeControlEnabled} />
@@ -293,7 +345,13 @@ function VolumeControlTab(): JSX.Element {
showSizeChanger: false,
}}
locale={{
emptyText: (
emptyText: isListError ? (
<div className={styles.empty} data-testid="volume-control-tab-error">
<Typography.Text color="danger">
Failed to load volume control rules. Please try again.
</Typography.Text>
</div>
) : (
<div className={styles.empty} data-testid="volume-control-tab-empty">
<Typography.Text color="muted">
No volume control rules yet. Open a metric and set one up to start

View File

@@ -49,6 +49,7 @@ export interface UseVolumeControlConfigResult {
const PREVIEW_DEBOUNCE_MS = 400;
const SAVE_ERROR_MESSAGE = 'Failed to save volume control rule';
const REMOVE_ERROR_MESSAGE = 'Failed to remove volume control rule';
const PREVIEW_ERROR_MESSAGE = 'Failed to preview volume control rule';
export function useVolumeControlConfig({
metricName,
@@ -95,11 +96,25 @@ export function useVolumeControlConfig({
const timer = setTimeout(() => {
previewMutate(
{ data: { metricName, matchType: matchTypeForMode(mode), labels } },
{ onSettled: () => setIsPreviewPending(false) },
{
onError: (error) =>
notifications.error({
message: error.response?.data?.error?.message ?? PREVIEW_ERROR_MESSAGE,
}),
onSettled: () => setIsPreviewPending(false),
},
);
}, PREVIEW_DEBOUNCE_MS);
return (): void => clearTimeout(timer);
}, [open, mode, labels, metricName, previewMutate, previewReset]);
}, [
open,
mode,
labels,
metricName,
previewMutate,
previewReset,
notifications,
]);
const createMutation = useCreateMetricReductionRule();
const updateMutation = useUpdateMetricReductionRuleByID();
@@ -142,7 +157,10 @@ export function useVolumeControlConfig({
}
const onSuccess = (): void => {
notifications.success({ message: 'Volume control rule saved' });
notifications.success({
message:
'Volume control rule saved. It takes about 5 minutes to take effect.',
});
invalidate();
onClose();
};

View File

@@ -9,7 +9,7 @@ export function isKeepMode(
export function getMatchTypeLabel(
matchType: MetricreductionruletypesMatchTypeDTO,
): string {
return isKeepMode(matchType) ? 'Include attributes' : 'Exclude attributes';
return isKeepMode(matchType) ? 'Include' : 'Exclude';
}
export function getLabelVerb(

View File

@@ -1,26 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { useMemo } from 'react';
import { ArrowRight, LoaderCircle } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Input } from '@signozhq/ui/input';
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import AuthError from 'components/AuthError/AuthError';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import { InviteMemberRow, InviteResult } from 'components/InviteMembers/types';
import { useRoles } from 'components/RolesSelect/RolesSelect';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce } from 'lodash-es';
import {
ArrowRight,
ChevronDown,
CircleAlert,
LoaderCircle,
Plus,
Trash2,
} from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
@@ -36,101 +22,41 @@ interface TeamMember {
interface InviteTeamMembersProps {
isLoading: boolean;
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
}
function InviteTeamMembers({
isLoading,
teamMembers,
setTeamMembers,
onNext,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
>(teamMembers);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const [inviteError, setInviteError] = useState<APIError | null>(null);
const { notifications } = useNotifications();
const { roles } = useRoles();
const defaultTeamMember: TeamMember = {
email: '',
role: '',
name: '',
frontendBaseUrl: getBaseUrl(),
id: '',
};
useEffect(() => {
if (teamMembers === null) {
const initialTeamMembers = Array.from({ length: 3 }, () => ({
...defaultTeamMember,
id: uuid(),
}));
setTeamMembersToInvite(initialTeamMembers);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
const handleAddTeamMember = (): void => {
const newTeamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
};
const handleRemoveTeamMember = (id: string): void => {
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
};
const isMemberTouched = (member: TeamMember): boolean =>
member.email.trim() !== '' ||
Boolean(member.role && member.role.trim() !== '');
const validateAllUsers = (): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedMembers = teamMembersToInvite?.filter(isMemberTouched) ?? [];
touchedMembers?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
const roleValid = Boolean(member.role && member.role.trim() !== '');
if (!emailValid || !member.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (member.id) {
updatedEmailValidity[member.id] = emailValid;
const roleIdToName = useMemo(() => {
const map: Record<string, string> = {};
roles.forEach((role) => {
if (role.id && role.name) {
map[role.id] = role.name;
}
});
return map;
}, [roles]);
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
const toTeamMembers = (rows: InviteMemberRow[]): TeamMember[] =>
rows.map((row) => ({
email: row.email,
role: roleIdToName[row.roleId] ?? row.roleId,
name: '',
frontendBaseUrl: getBaseUrl(),
id: row.id,
}));
return isValid;
};
const handleInviteUsersSuccess = (): void => {
const handleSuccess = (
_results: InviteResult[],
rows: InviteMemberRow[],
): void => {
logEvent('Org Onboarding: Invite Team Members Success', {
teamMembers: teamMembersToInvite,
teamMembers: toTeamMembers(rows),
});
notifications.success({
message: 'Invites sent successfully!',
@@ -140,125 +66,34 @@ function InviteTeamMembers({
}, 1000);
};
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
onSuccess: (): void => {
handleInviteUsersSuccess();
},
onError: (error: APIError): void => {
logEvent('Org Onboarding: Invite Team Members Failed', {
teamMembers: teamMembersToInvite,
});
setInviteError(error);
},
},
);
const handleNext = (): void => {
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite?.filter(isMemberTouched) ?? []);
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteError(null);
sendInvites({
invites: teamMembersToInvite?.filter(isMemberTouched) ?? [],
});
}
const handlePartialSuccess = (
_results: InviteResult[],
rows: InviteMemberRow[],
): void => {
logEvent('Org Onboarding: Invite Team Members Partial Success', {
teamMembers: toTeamMembers(rows),
});
notifications.warning({
message: 'Some invites failed. Check the errors above.',
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
// Clear hasInvalidEmails only when ALL emails are valid
if (hasInvalidEmails) {
const allEmailsValid = updatedMembers.every(
(m) => m.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.email),
);
if (allEmailsValid) {
setHasInvalidEmails(false);
}
}
}, 500),
[hasInvalidEmails],
);
const handleEmailChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, member: TeamMember): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate && member.id) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id, updatedMembers);
// Clear API error when user starts typing
if (inviteError) {
setInviteError(null);
}
}
},
[debouncedValidateEmail, inviteError, teamMembersToInvite],
);
const createEmailChangeHandler = useCallback(
(member: TeamMember) =>
(e: React.ChangeEvent<HTMLInputElement>): void => {
handleEmailChange(e, member);
},
[handleEmailChange],
);
const handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate && member.id) {
memberToUpdate.role = role;
setTeamMembersToInvite(updatedMembers);
// Clear errors when user selects a role
if (hasInvalidRoles) {
// Check if all roles are now valid
const allRolesValid = updatedMembers.every(
(m) => m.role && m.role.trim() !== '',
);
if (allRolesValid) {
setHasInvalidRoles(false);
}
}
if (inviteError) {
setInviteError(null);
}
}
};
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
const handleAllFailed = (
_results: InviteResult[],
rows: InviteMemberRow[],
): void => {
logEvent('Org Onboarding: Invite Team Members Failed', {
teamMembers: toTeamMembers(rows),
});
};
const handleDoLater = (): void => {
logEvent('Org Onboarding: Clicked Do Later', {
currentPageID: 4,
});
onNext();
};
const hasInvites =
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
const isButtonDisabled = isSendingInvites || isLoading;
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
return (
<div className="questions-container">
<OnboardingQuestionHeader
@@ -273,126 +108,52 @@ function InviteTeamMembers({
Invite your team to the SigNoz workspace
</div>
<div className="invite-team-members-table">
<div className="invite-team-members-table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<InviteMembers
onSuccess={handleSuccess}
onPartialSuccess={handlePartialSuccess}
onAllFailed={handleAllFailed}
showHeader
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => {
const isButtonDisabled = isSubmitting || isLoading;
const isInviteButtonDisabled = isButtonDisabled || !canSubmit;
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-row" key={member.id}>
<div className="team-member-cell email-cell">
<Input
placeholder="e.g. john@signoz.io"
value={member.email}
type="email"
id={`email-input-${member.id}`}
name={`email-input-${member.id}`}
required
autoComplete="off"
className="team-member-email-input"
onChange={createEmailChangeHandler(member)}
/>
{member.id &&
emailValidity[member.id] === false &&
member.email.trim() !== '' && (
<Typography.Text className="email-error-message">
Invalid email address
</Typography.Text>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={member.role || undefined}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{teamMembersToInvite && teamMembersToInvite.length > 1 && (
<Button
variant="ghost"
color="secondary"
className="remove-team-member-button"
onClick={(): void => handleRemoveTeamMember(member.id)}
aria-label="Remove team member"
>
<Trash2 size={12} />
</Button>
)}
</div>
return (
<div className="onboarding-buttons-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={submit}
disabled={isInviteButtonDisabled}
data-testid="send-invites-button"
suffix={
isButtonDisabled ? (
<LoaderCircle className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
</Button>
<Button
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleDoLater}
disabled={isButtonDisabled}
data-testid="do-later-button"
>
I&apos;ll do this later
</Button>
</div>
))}
</div>
<div className="invite-team-members-add-another-member-container">
<Button
variant="dashed"
color="secondary"
className="add-another-member-button"
prefix={<Plus size={12} />}
onClick={handleAddTeamMember}
>
Add another
</Button>
</div>
</div>
);
}}
/>
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="invite-team-members-error-callout"
>
{getValidationErrorMessage()}
</Callout>
)}
{inviteError && !hasInvalidEmails && !hasInvalidRoles && (
<AuthError error={inviteError} />
)}
<div className="onboarding-buttons-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isInviteButtonDisabled ? 'disabled' : ''
}`}
onClick={handleNext}
disabled={isInviteButtonDisabled}
suffix={
isButtonDisabled ? (
<LoaderCircle className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
</Button>
<Button
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleDoLater}
disabled={isButtonDisabled}
>
I&apos;ll do this later
</Button>
</div>
</div>
</div>
);

View File

@@ -1,97 +1,86 @@
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
InviteMemberRow,
InviteMembersProps,
InviteResult,
} from 'components/InviteMembers/types';
import logEvent from 'api/common/logEvent';
import { render, screen, userEvent } from 'tests/test-utils';
import InviteTeamMembers from '../InviteTeamMembers';
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;
const mockNotificationError = jest.fn() as jest.MockedFunction<
(args: { message: string }) => void
>;
const mockNotificationSuccess = jest.fn();
const mockNotificationWarning = jest.fn();
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
success: mockNotificationSuccess,
error: mockNotificationError,
warning: mockNotificationWarning,
},
}),
}));
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk';
jest.mock('api/common/logEvent', () => jest.fn());
interface TeamMember {
email: string;
role: string;
name: string;
frontendBaseUrl: string;
id: string;
}
jest.mock('components/RolesSelect/RolesSelect', () => ({
useRoles: (): any => ({
roles: [
{ id: 'role-viewer-id', name: 'VIEWER' },
{ id: 'role-editor-id', name: 'EDITOR' },
{ id: 'role-admin-id', name: 'ADMIN' },
],
isLoading: false,
isError: false,
error: undefined,
refetch: jest.fn(),
}),
}));
interface InviteRequestBody {
invites: { email: string; role: string }[];
}
jest.mock('utils/basePath', () => ({
...jest.requireActual('utils/basePath'),
getBaseUrl: (): string => 'http://localhost:3301',
}));
interface RenderProps {
isLoading?: boolean;
teamMembers?: TeamMember[] | null;
}
let mockInviteMembersProps: InviteMembersProps | null = null;
const mockOnNext = jest.fn() as jest.MockedFunction<() => void>;
const mockSetTeamMembers = jest.fn() as jest.MockedFunction<
(members: TeamMember[]) => void
>;
jest.mock('components/InviteMembers/InviteMembers', () => {
return function MockInviteMembers(props: InviteMembersProps): JSX.Element {
mockInviteMembersProps = props;
return (
<div data-testid="mock-invite-members">
{props.renderFooter?.({
submit: jest.fn().mockResolvedValue([]),
reset: jest.fn(),
canSubmit: true,
isSubmitting: false,
touchedCount: 0,
})}
</div>
);
};
});
const mockOnNext = jest.fn();
function renderComponent({
isLoading = false,
teamMembers = null,
}: RenderProps = {}): ReturnType<typeof render> {
return render(
<InviteTeamMembers
isLoading={isLoading}
teamMembers={teamMembers}
setTeamMembers={mockSetTeamMembers}
onNext={mockOnNext}
/>,
);
}
async function selectRole(
user: ReturnType<typeof userEvent.setup>,
selectIndex: number,
optionLabel: string,
): Promise<void> {
const placeholders = screen.getAllByText(/select roles/i);
await user.click(placeholders[selectIndex]);
const optionContent = await screen.findByText(optionLabel);
fireEvent.click(optionContent);
}: { isLoading?: boolean } = {}): ReturnType<typeof render> {
return render(<InviteTeamMembers isLoading={isLoading} onNext={mockOnNext} />);
}
describe('InviteTeamMembers', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
jest.useFakeTimers();
mockInviteMembersProps = null;
});
afterEach(() => {
jest.useRealTimers();
server.resetHandlers();
});
describe('Initial rendering', () => {
it('renders the page header, column labels, default rows, and action buttons', () => {
describe('rendering', () => {
it('renders header and InviteMembers component', () => {
renderComponent();
expect(
@@ -100,11 +89,20 @@ describe('InviteTeamMembers', () => {
expect(
screen.getByText(/signoz is a lot more useful with collaborators/i),
).toBeInTheDocument();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(3);
expect(screen.getByText('Email address')).toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
});
it('passes showHeader=true to InviteMembers', () => {
renderComponent();
expect(mockInviteMembersProps?.showHeader).toBe(true);
});
});
describe('footer buttons', () => {
it('renders Send Invites and Do Later buttons', () => {
renderComponent();
expect(
screen.getByRole('button', { name: /send invites/i }),
).toBeInTheDocument();
@@ -113,7 +111,7 @@ describe('InviteTeamMembers', () => {
).toBeInTheDocument();
});
it('disables both action buttons while isLoading is true', () => {
it('disables buttons when isLoading=true', () => {
renderComponent({ isLoading: true });
expect(screen.getByRole('button', { name: /send invites/i })).toBeDisabled();
@@ -121,355 +119,181 @@ describe('InviteTeamMembers', () => {
screen.getByRole('button', { name: /i'll do this later/i }),
).toBeDisabled();
});
});
describe('Row management', () => {
it('adds a new empty row when "Add another" is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
it('disables Send Invites when canSubmit=false from InviteMembers', () => {
const { unmount } = renderComponent();
unmount();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(3);
await user.click(screen.getByRole('button', { name: /add another/i }));
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(4);
});
it('removes the correct row when its trash icon is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const emailInputs = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(emailInputs[0], 'first@example.com');
await screen.findByDisplayValue('first@example.com');
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
const { getByTestId } = render(
mockInviteMembersProps?.renderFooter?.({
submit: jest.fn().mockResolvedValue([]),
reset: jest.fn(),
canSubmit: false,
isSubmitting: false,
touchedCount: 0,
}) as JSX.Element,
);
await waitFor(() => {
expect(
screen.queryByDisplayValue('first@example.com'),
).not.toBeInTheDocument();
expect(
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
).toHaveLength(2);
});
expect(getByTestId('send-invites-button')).toBeDisabled();
expect(getByTestId('do-later-button')).not.toBeDisabled();
});
it('hides remove buttons when only one row remains', async () => {
renderComponent();
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('disables buttons when isSubmitting=true from InviteMembers', () => {
const { unmount } = renderComponent();
unmount();
let removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
while (removeButtons.length > 0) {
await user.click(removeButtons[0]);
removeButtons = screen.queryAllByRole('button', {
name: /remove team member/i,
});
}
const { getByTestId } = render(
mockInviteMembersProps?.renderFooter?.({
submit: jest.fn().mockResolvedValue([]),
reset: jest.fn(),
canSubmit: true,
isSubmitting: true,
touchedCount: 0,
}) as JSX.Element,
);
expect(
screen.queryByRole('button', { name: /remove team member/i }),
).not.toBeInTheDocument();
expect(getByTestId('send-invites-button')).toBeDisabled();
expect(getByTestId('do-later-button')).toBeDisabled();
});
});
describe('Inline email validation', () => {
it('shows an inline error after typing an invalid email and clears it when a valid email is entered', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
});
describe('handleSuccess callback', () => {
it('logs event with teamMembers in correct shape, shows success notification, and calls onNext after delay', () => {
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
const mockResults: InviteResult[] = [
{ email: 'user1@test.com', success: true },
{ email: 'user2@test.com', success: true },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-editor-id' },
];
mockInviteMembersProps?.onSuccess?.(mockResults, mockRows);
await user.type(firstInput, 'not-an-email');
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'good@example.com');
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(
screen.queryByText(/invalid email address/i),
).not.toBeInTheDocument();
});
});
it('does not show an inline error when the field is cleared back to empty', async () => {
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
});
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'a');
await user.clear(firstInput);
jest.advanceTimersByTime(600);
await waitFor(() => {
expect(
screen.queryByText(/invalid email address/i),
).not.toBeInTheDocument();
});
});
});
describe('Validation callout on Complete', () => {
it('shows the correct callout message for each combination of email/role validity', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderComponent();
const removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
await user.click(removeButtons[0]);
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(
/please enter valid emails and select roles for team members/i,
),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await selectRole(user, 0, 'Viewer');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(/please enter valid emails for team members/i),
).toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'valid@example.com');
await user.click(screen.getByRole('button', { name: /add another/i }));
const allInputs = screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i);
await user.type(allInputs[1], 'norole@example.com');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(/please select roles for team members/i),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
});
}, 15000);
it('treats whitespace as untouched, clears the callout on fix-and-resubmit, and clears role error on role select', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
renderComponent();
const removeButtons = screen.getAllByRole('button', {
name: /remove team member/i,
});
await user.click(removeButtons[0]);
await user.click(
screen.getAllByRole('button', { name: /remove team member/i })[0],
);
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, ' ');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.queryByText(/please enter valid emails/i),
).not.toBeInTheDocument();
expect(screen.queryByText(/please select roles/i)).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'bad-email');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.getByText(
/please enter valid emails and select roles for team members/i,
),
).toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await user.clear(firstInput);
await user.type(firstInput, 'good@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(
screen.queryByText(/please enter valid emails and select roles/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please enter valid emails for team members/i),
).not.toBeInTheDocument();
expect(
screen.queryByText(/please select roles for team members/i),
).not.toBeInTheDocument();
});
await waitFor(() => expect(mockOnNext).toHaveBeenCalledTimes(1), {
timeout: 1200,
});
}, 15000);
it('disables the Send Invites button when all rows are untouched (empty)', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const sendInvitesBtn = screen.getByRole('button', { name: /send invites/i });
expect(sendInvitesBtn).toBeDisabled();
// Type something to make a row touched
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'a');
expect(sendInvitesBtn).not.toBeDisabled();
});
});
describe('API integration', () => {
it('only sends touched (non-empty) rows — empty rows are excluded from the invite payload', async () => {
let capturedBody: InviteRequestBody | null = null;
server.use(
rest.post(INVITE_USERS_ENDPOINT, async (req, res, ctx) => {
capturedBody = await req.json<InviteRequestBody>();
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'only@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(capturedBody).not.toBeNull();
expect(capturedBody?.invites).toHaveLength(1);
expect(capturedBody?.invites[0]).toMatchObject({
email: 'only@example.com',
role: 'ADMIN',
});
});
await waitFor(() => expect(mockOnNext).toHaveBeenCalled(), {
timeout: 1200,
});
});
it('calls the invite API, shows a success notification, and calls onNext after the 1 s delay', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
);
await user.type(firstInput, 'alice@example.com');
await selectRole(user, 0, 'Admin');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(mockNotificationSuccess).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Invites sent successfully!' }),
);
});
await waitFor(
() => {
expect(mockOnNext).toHaveBeenCalledTimes(1);
expect(logEvent).toHaveBeenCalledWith(
'Org Onboarding: Invite Team Members Success',
{
teamMembers: [
{
email: 'user1@test.com',
role: 'VIEWER',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'EDITOR',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
},
],
},
{ timeout: 1200 },
);
expect(mockNotificationSuccess).toHaveBeenCalledWith({
message: 'Invites sent successfully!',
});
expect(mockOnNext).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(mockOnNext).toHaveBeenCalledTimes(1);
});
});
it('renders an API error container when the invite request fails', async () => {
server.use(
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
errors: [{ code: 'INTERNAL_ERROR', msg: 'Something went wrong' }],
}),
),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
describe('handlePartialSuccess callback', () => {
it('logs event with teamMembers in correct shape and shows warning notification', () => {
renderComponent();
const [firstInput] = screen.getAllByPlaceholderText(
/e\.g\. john@signoz\.io/i,
const mockResults: InviteResult[] = [
{ email: 'user1@test.com', success: true },
{ email: 'user2@test.com', success: false, error: 'Already exists' },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-admin-id' },
];
mockInviteMembersProps?.onPartialSuccess?.(mockResults, mockRows);
expect(logEvent).toHaveBeenCalledWith(
'Org Onboarding: Invite Team Members Partial Success',
{
teamMembers: [
{
email: 'user1@test.com',
role: 'VIEWER',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'ADMIN',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
},
],
},
);
await user.type(firstInput, 'fail@example.com');
await selectRole(user, 0, 'Viewer');
await user.click(screen.getByRole('button', { name: /send invites/i }));
await waitFor(() => {
expect(document.querySelector('.auth-error-container')).toBeInTheDocument();
expect(mockNotificationWarning).toHaveBeenCalledWith({
message: 'Some invites failed. Check the errors above.',
});
});
});
await user.type(firstInput, 'x');
await waitFor(() => {
expect(
document.querySelector('.auth-error-container'),
).not.toBeInTheDocument();
describe('handleAllFailed callback', () => {
it('logs event with teamMembers in correct shape', () => {
renderComponent();
const mockResults: InviteResult[] = [
{ email: 'user1@test.com', success: false, error: 'Error 1' },
{ email: 'user2@test.com', success: false, error: 'Error 2' },
];
const mockRows: InviteMemberRow[] = [
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-editor-id' },
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-viewer-id' },
];
mockInviteMembersProps?.onAllFailed?.(mockResults, mockRows);
expect(logEvent).toHaveBeenCalledWith(
'Org Onboarding: Invite Team Members Failed',
{
teamMembers: [
{
email: 'user1@test.com',
role: 'EDITOR',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-1',
},
{
email: 'user2@test.com',
role: 'VIEWER',
name: '',
frontendBaseUrl: 'http://localhost:3301',
id: 'row-2',
},
],
},
);
});
});
describe('handleDoLater', () => {
it('logs event and calls onNext immediately', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
renderComponent();
await user.click(
screen.getByRole('button', { name: /i'll do this later/i }),
);
expect(logEvent).toHaveBeenCalledWith('Org Onboarding: Clicked Do Later', {
currentPageID: 4,
});
expect(mockOnNext).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -143,6 +143,8 @@
}
&.invite-team-members-form {
--invite-members-field-background: var(--l3-background);
padding-right: 12px;
.form-group {

View File

@@ -22,7 +22,8 @@ const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
const UPDATE_PROFILE_ENDPOINT = '*/api/v2/zeus/profiles';
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
const CREATE_USER_ENDPOINT = '*/api/v2/users';
const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
const mockOrgPreferences = {
data: {
@@ -31,6 +32,12 @@ const mockOrgPreferences = {
status: 'success',
};
const MOCK_ROLES = [
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
];
describe('OnboardingQuestionaire Component', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -48,8 +55,11 @@ describe('OnboardingQuestionaire Component', () => {
rest.post(UPDATE_ORG_PREFERENCE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
),
rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
),
);
});

View File

@@ -11,7 +11,6 @@ import { AxiosError } from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
@@ -71,9 +70,6 @@ function OnboardingQuestionaire(): JSX.Element {
const [optimiseSignozDetails, setOptimiseSignozDetails] =
useState<OptimiseSignozDetails>(INITIAL_OPTIMISE_SIGNOZ_DETAILS);
const [teamMembers, setTeamMembers] = useState<
InviteTeamMembersProps[] | null
>(null);
const [updatingOrgOnboardingStatus, setUpdatingOrgOnboardingStatus] =
useState<boolean>(false);
@@ -232,8 +228,6 @@ function OnboardingQuestionaire(): JSX.Element {
{currentStep === 4 && (
<InviteTeamMembers
isLoading={updatingOrgOnboardingStatus}
teamMembers={teamMembers}
setTeamMembers={setTeamMembers}
onNext={handleOnboardingComplete}
/>
)}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
import { ArrowRight, Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
import {
Button,
Flex,
@@ -10,6 +10,8 @@ import {
Space,
Steps,
} from 'antd';
import { Button as SignozButton } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
@@ -27,7 +29,7 @@ import { isModifierKeyPressed } from 'utils/app';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import OnboardingIngestionDetails from '../IngestionDetails/IngestionDetails';
import InviteTeamMembers from '../InviteTeamMembers/InviteTeamMembers';
import InviteMembers from 'components/InviteMembers/InviteMembers';
import onboardingConfigWithLinks from '../onboarding-configs/onboarding-config-with-links';
import '../OnboardingV2.styles.scss';
@@ -119,6 +121,10 @@ const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
GET_HELP_BUTTON_CLICKED: 'Get help clicked',
GET_EXPERT_ASSISTANCE_BUTTON_CLICKED: 'Get expert assistance clicked',
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Invite team member clicked',
INVITE_TEAM_MEMBER_SEND_CLICKED: 'Send invites clicked',
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
CLOSE_ONBOARDING_CLICKED: 'Close onboarding clicked',
DATA_SOURCE_REQUESTED: 'Datasource requested',
DATA_SOURCE_SEARCHED: 'Searched',
@@ -1147,12 +1153,54 @@ function OnboardingAddDataSource(): JSX.Element {
destroyOnClose
>
<div className="invite-team-member-modal-content">
<InviteTeamMembers
isLoading={false}
teamMembers={null}
setTeamMembers={(): void => {}}
onNext={(): void => setShowInviteTeamMembersModal(false)}
onClose={(): void => setShowInviteTeamMembersModal(false)}
<InviteMembers
onSuccess={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
{},
);
setShowInviteTeamMembersModal(false);
toast.success('Invites sent successfully', { position: 'top-center' });
}}
onPartialSuccess={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_PARTIAL_SUCCESS}`,
{},
);
}}
onAllFailed={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
{},
);
}}
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
<div className="invite-team-member-modal-footer">
<SignozButton
variant="solid"
color="secondary"
onClick={(): void => setShowInviteTeamMembersModal(false)}
>
Cancel
</SignozButton>
<SignozButton
variant="solid"
onClick={(): void => {
void logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SEND_CLICKED}`,
{},
);
void submit();
}}
disabled={!canSubmit}
loading={isSubmitting}
suffix={<ArrowRight size={14} />}
>
Send Invites
</SignozButton>
</div>
)}
/>
</div>
</Modal>

View File

@@ -1,116 +0,0 @@
.team-member-container {
display: flex;
align-items: center;
.invite-team-members-form {
padding: 16px 0px;
}
.team-member-email-input {
width: 80%;
background-color: var(--l1-background);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-input,
.ant-input-group-addon {
background-color: var(--l1-background) !important;
border-right: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.team-member-role-select {
width: 20%;
.ant-select-selector {
border: 1px solid var(--l1-border);
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
.remove-team-member-button {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.invite-team-members-container {
display: flex;
flex-direction: column;
gap: 8px;
.invite-team-members-add-another-member-container {
margin: 16px 0px;
display: flex;
justify-content: flex-end;
}
.next-prev-container {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.error-message-container,
.success-message-container,
.partially-sent-invites-container {
border-radius: 4px;
width: 100%;
display: flex;
align-items: center;
.error-message,
.success-message {
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
.invite-users-error-message-container,
.invite-users-success-message-container {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.success-message {
color: var(--bg-success-500, #00b37e);
}
}
.partially-sent-invites-container {
margin-top: 16px;
padding: 8px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
box-sizing: border-box;
.partially-sent-invites-message {
color: var(--bg-warning-500, #fbbd23);
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 8px;
}
}
}

View File

@@ -1,298 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
import {
ArrowRight,
CircleCheck,
Plus,
TriangleAlert,
X,
} from '@signozhq/icons';
import APIError from 'types/api/error';
import { getBaseUrl } from 'utils/basePath';
import { v4 as uuid } from 'uuid';
import './InviteTeamMembers.styles.scss';
interface TeamMember {
email: string;
role: string;
name: string;
frontendBaseUrl: string;
id: string;
}
interface InviteTeamMembersProps {
isLoading: boolean;
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
onClose: () => void;
}
const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
BASE: 'Onboarding V3',
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Send invites clicked',
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
};
function InviteTeamMembers({
isLoading,
teamMembers,
setTeamMembers,
onNext,
onClose,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
>(teamMembers);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const { notifications } = useNotifications();
const defaultTeamMember: TeamMember = {
email: '',
role: 'EDITOR',
name: '',
frontendBaseUrl: getBaseUrl(),
id: '',
};
useEffect(() => {
if (isEmpty(teamMembers)) {
const teamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite([teamMember]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
const handleAddTeamMember = (): void => {
const newTeamMember = {
...defaultTeamMember,
id: uuid(),
};
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
};
const handleRemoveTeamMember = (id: string): void => {
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
};
// Validation function to check all users
const validateAllUsers = (): boolean => {
let isValid = true;
const updatedValidity: Record<string, boolean> = {};
teamMembersToInvite?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
if (!emailValid || !member.email) {
isValid = false;
setHasInvalidEmails(true);
}
updatedValidity[member.id!] = emailValid;
});
setEmailValidity(updatedValidity);
return isValid;
};
const handleInviteUsersSuccess = (): void => {
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
{
teamMembers: teamMembersToInvite,
},
);
setTimeout(() => {
onNext();
}, 1000);
};
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
inviteUsers,
{
onSuccess: (): void => {
handleInviteUsersSuccess();
notifications.success({
message: 'Invites sent successfully!',
});
},
onError: (error: APIError): void => {
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
{
teamMembers: teamMembersToInvite,
error,
},
);
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
},
},
);
const handleNext = (): void => {
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite || []);
logEvent(
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
{
teamMembers: teamMembersToInvite,
},
);
setHasInvalidEmails(false);
sendInvites({
invites: teamMembersToInvite || [],
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
}, 500),
[],
);
const handleEmailChange = (
e: React.ChangeEvent<HTMLInputElement>,
member: TeamMember,
): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id!);
}
};
const handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.role = role;
setTeamMembersToInvite(updatedMembers);
}
};
return (
<div className="invite-team-members-container">
<div className="invite-team-members-form">
<div className="form-group">
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-container" key={member.id}>
<Input
placeholder="your-teammate@org.com"
value={member.email}
type="email"
required
autoFocus
autoComplete="off"
className="team-member-email-input"
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
handleEmailChange(e, member)
}
addonAfter={
emailValidity[member.id!] === undefined ? null : emailValidity[
member.id!
] ? (
<CircleCheck size={14} color={Color.BG_FOREST_500} />
) : (
<TriangleAlert size={14} color={Color.BG_SIENNA_500} />
)
}
/>
<Select
defaultValue={member.role}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
{teamMembersToInvite?.length > 1 && (
<Button
type="default"
className="remove-team-member-button periscope-btn"
icon={<X size={14} />}
onClick={(): void => handleRemoveTeamMember(member.id)}
/>
)}
</div>
))}
</div>
<div className="invite-team-members-add-another-member-container">
<Button
type="primary"
className="add-another-member-button periscope-btn"
icon={<Plus size={14} />}
onClick={handleAddTeamMember}
>
Member
</Button>
</div>
</div>
{hasInvalidEmails && (
<div className="error-message-container">
<Typography.Text className="error-message" color="danger">
<TriangleAlert size={14} /> Please enter valid emails for all team
members
</Typography.Text>
</div>
)}
</div>
<div className="next-prev-container">
<Button
type="default"
className="next-button periscope-btn"
onClick={onClose}
>
<X size={14} />
Cancel
</Button>
<Button
type="primary"
className="next-button periscope-btn primary"
onClick={handleNext}
loading={isSendingInvites || isLoading}
>
Send Invites
<ArrowRight size={14} />
</Button>
</div>
</div>
);
}
export default InviteTeamMembers;

View File

@@ -1220,6 +1220,14 @@
.request-data-source-modal-input {
margin-top: 8px;
}
.invite-team-member-modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
border-top: 1px solid var(--l1-border);
padding-top: var(--spacing-6);
}
}
.request-data-source-modal {

View File

@@ -79,7 +79,7 @@
margin-bottom: 12px;
}
input,
input:not(.ant-select-selection-search-input),
textarea {
height: 32px;
background: var(--l2-background) !important;

View File

@@ -111,31 +111,9 @@
&__select {
width: 100%;
&.ant-select {
.ant-select-selector {
height: 32px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
border-radius: 2px;
color: var(--l2-foreground) !important;
.ant-select-selection-item {
color: var(--l2-foreground) !important;
}
}
&:hover .ant-select-selector {
border-color: var(--l2-border) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--primary) !important;
box-shadow: none !important;
}
.ant-select-arrow {
color: var(--l2-foreground);
}
.ant-select-selection-search {
inset-inline-start: var(--padding-2) !important;
inset-inline-end: var(--padding-2) !important;
}
}
@@ -185,7 +163,7 @@
&--role {
flex: 1;
min-width: 120px;
min-width: 180px;
}
}
@@ -272,7 +250,7 @@
}
// todo: https://github.com/SigNoz/components/issues/116
input {
input:not(.ant-select-selection-search-input) {
height: 32px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;

View File

@@ -11,23 +11,20 @@ import {
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { Collapse, Form, Tooltip } from 'antd';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './RoleMappingSection.styles.scss';
const ROLE_OPTIONS = [
{ value: 'VIEWER', label: 'VIEWER' },
{ value: 'EDITOR', label: 'EDITOR' },
{ value: 'ADMIN', label: 'ADMIN' },
];
interface RoleMappingSectionProps {
fieldNamePrefix: string[];
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
const SIGNOZ_VIEWER_ROLE = 'signoz-viewer';
function RoleMappingSection({
fieldNamePrefix,
isExpanded,
@@ -38,6 +35,7 @@ function RoleMappingSection({
[...fieldNamePrefix, 'useRoleAttribute'],
form,
);
const { roles, isLoading, isError, error, refetch } = useRoles();
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -108,19 +106,26 @@ function RoleMappingSection({
<div className="role-mapping-section__field-group">
<label className="role-mapping-section__label" htmlFor="default-role">
Default Role
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "signoz-viewer"'>
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'defaultRole']}
className="role-mapping-section__form-item"
initialValue="VIEWER"
initialValue={SIGNOZ_VIEWER_ROLE}
>
<Select
<RolesSelect
id="default-role"
options={ROLE_OPTIONS}
valueField="name"
roles={roles}
loading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
className="role-mapping-section__select"
allowClear={false}
getPopupContainer={(): HTMLElement => document.body}
/>
</Form.Item>
</div>
@@ -140,7 +145,7 @@ function RoleMappingSection({
Use Role Attribute Directly
</Checkbox>
</Form.Item>
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role name (e.g. signoz-viewer, signoz-editor, signoz-admin, or a custom role).">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
@@ -174,11 +179,17 @@ function RoleMappingSection({
name={[field.name, 'role']}
className="role-mapping-section__field role-mapping-section__field--role"
rules={[{ required: true, message: 'Role is required' }]}
initialValue="VIEWER"
initialValue={SIGNOZ_VIEWER_ROLE}
>
<Select
options={ROLE_OPTIONS}
className="role-mapping-section__select"
<RolesSelect
valueField="name"
roles={roles}
loading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
allowClear={false}
getPopupContainer={(): HTMLElement => document.body}
/>
</Form.Item>
@@ -197,7 +208,9 @@ function RoleMappingSection({
<Button
variant="outlined"
color="secondary"
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
onClick={(): void =>
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
}
prefix={<Plus size={14} />}
>
Add Group Mapping

View File

@@ -9,6 +9,7 @@ import {
mockUpdateSuccessResponse,
} from './mocks';
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
// which mocks @signozhq/ui/switch for the same reason.

View File

@@ -0,0 +1,316 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
allRoles,
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithDirectRoleAttribute,
mockDomainWithRoleMapping,
mockSamlAuthDomain,
mockUpdateSuccessResponse,
} from './mocks';
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
// prevent form.validateFields() from resolving inside act(). Replacing with a
// simple native button avoids the issue.
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
// These are heavy real-timer integration tests (antd Select dropdown render +
// form.validateFields() + a react-query mutation, all driven through userEvent).
// Under a CPU-saturated parallel `jest` run the wall-clock roughly triples, which
// pushes the longest tests past the 5000ms default and makes them flaky. Give the
// whole file a wider budget (matches LogsPanelComponent.test.tsx).
jest.setTimeout(20000);
const ROLES_ENDPOINT = '*/api/v1/roles';
type User = ReturnType<typeof userEvent.setup>;
// antd renders pointer-events:none on parts of its Select, so disable the
// userEvent pointer-events guard (mirrors CreateEdit.test.tsx).
const setupUser = (): User => userEvent.setup({ pointerEventsCheck: 0 });
function getRole(name: string): (typeof managedRoles)[number] {
const role = managedRoles.find((r) => r.name === name);
if (!role) {
throw new Error(`missing mock role: ${name}`);
}
return role;
}
const viewerRole = getRole('signoz-viewer');
const editorRole = getRole('signoz-editor');
function mockRoles(
response: Record<string, unknown> = listRolesSuccessResponse,
status = 200,
): { count: () => number } {
let requested = 0;
server.use(
rest.get(ROLES_ENDPOINT, (_req, res, ctx) => {
requested += 1;
return res(ctx.status(status), ctx.json(response));
}),
);
return { count: (): number => requested };
}
function captureUpdatePayload(): { get: () => any } {
let payload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
payload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
return { get: (): any => payload };
}
const expandRoleMapping = (user: User): Promise<void> =>
user.click(screen.getByText(/role mapping \(advanced\)/i));
const openDefaultRoleSelect = (user: User): Promise<void> =>
user.click(screen.getByLabelText(/default role/i));
const saveChanges = (user: User): Promise<void> =>
user.click(screen.getByRole('button', { name: /save changes/i }));
describe('CreateEdit — role mapping uses API roles', () => {
afterEach(() => {
server.resetHandlers();
});
it('fetches the roles list from the API when the form mounts', async () => {
const roles = mockRoles();
render(
<CreateEdit
isCreate={false}
record={mockDomainWithDirectRoleAttribute}
onClose={jest.fn()}
/>,
);
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
});
it('renders the default-role options from the API (managed + custom), not the old hardcoded VIEWER/EDITOR/ADMIN', async () => {
const user = setupUser();
mockRoles();
// mockSamlAuthDomain has no stored defaultRole, so nothing stale (e.g.
// "VIEWER") is rendered as a selected tag to pollute the title lookups.
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// Open the Select and wait for the async roles fetch to populate it.
await openDefaultRoleSelect(user);
await screen.findByTitle(allRoles[0].name);
// Every role returned by the API is offered as an option, including the
// custom (non-managed) roles — the whole point of the refactor. Use
// getAllByTitle: the preselected default role also renders its name on
// the selection item, so a role may legitimately appear more than once.
allRoles.forEach((role) => {
expect(screen.getAllByTitle(role.name).length).toBeGreaterThan(0);
});
// The old hardcoded uppercase role values must NOT appear as options.
expect(screen.queryByTitle('VIEWER')).not.toBeInTheDocument();
expect(screen.queryByTitle('EDITOR')).not.toBeInTheDocument();
expect(screen.queryByTitle('ADMIN')).not.toBeInTheDocument();
});
it('submits the selected role name (not the role id) as defaultRole', async () => {
const user = setupUser();
mockRoles();
const payload = captureUpdatePayload();
render(
<CreateEdit
isCreate={false}
record={mockDomainWithDirectRoleAttribute}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
await openDefaultRoleSelect(user);
await user.click(await screen.findByTitle(editorRole.name));
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
});
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
const user = setupUser();
const roles = mockRoles();
const payload = captureUpdatePayload();
// mockSamlAuthDomain has no roleMapping, so the defaultRole field falls
// back to the Form.Item initialValue (viewerRole.name). That initialValue
// is only applied when the field mounts, so the roles fetch MUST resolve
// before the panel is expanded — otherwise viewerRole is still undefined.
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
// Flush the react-query commit so `useRoles` exposes the loaded roles
// before the collapse panel (and thus the default-role field) mounts.
await screen.findByText(/edit saml authentication/i);
await expandRoleMapping(user);
await screen.findByText(/default role/i);
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
});
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
const user = setupUser();
// signoz-viewer is a managed role that always exists server-side, so even
// a degenerate/empty roles response must not strip the hardcoded default.
mockRoles({ status: 'success', data: [] });
const payload = captureUpdatePayload();
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
// Section still renders without crashing even though the fetch was empty.
await expandRoleMapping(user);
await expect(screen.findByText(/default role/i)).resolves.toBeInTheDocument();
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
});
it('loads a stored role mapping by role name and round-trips it on save', async () => {
const user = setupUser();
mockRoles();
const payload = captureUpdatePayload();
// mockDomainWithRoleMapping stores defaultRole "signoz-editor" plus three
// group mappings, all keyed by role *name*. Editing must surface each
// stored value as the matching option and submit it unchanged — the
// backward-compatible read path for already-saved SSO domains.
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// The stored default role renders as a real selection, not a raw token.
await waitFor(() =>
expect(screen.getAllByTitle(editorRole.name).length).toBeGreaterThan(0),
);
await saveChanges(user);
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
});
});
it('shows an error state in the default-role select when the roles request fails', async () => {
const user = setupUser();
mockRoles(
{ error: { code: 'internal_error', message: 'boom', url: '' } },
500,
);
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={jest.fn()}
/>,
);
await expandRoleMapping(user);
// Open the select and confirm the error UI (with retry) is surfaced
// instead of crashing the form. The error message comes straight from
// the failed request; the Retry affordance is always present.
await openDefaultRoleSelect(user);
await expect(screen.findByTitle('Retry')).resolves.toBeInTheDocument();
expect(screen.getByText('boom')).toBeInTheDocument();
});
});

View File

@@ -186,9 +186,9 @@ describe('CreateEdit — payload sanitization', () => {
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
});
});
});

View File

@@ -75,12 +75,12 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
},
@@ -103,7 +103,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
},

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie } from '@visx/shape';
@@ -8,12 +8,10 @@ import { themeColors } from 'constants/theme';
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import { useIsDarkMode } from 'hooks/useDarkMode';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { isNaN } from 'lodash-es';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
import { preparePieChartData } from './preparePieChartData';
import { lightenColor, tooltipStyles } from './utils';
import './PiePanelWrapper.styles.scss';
@@ -44,37 +42,15 @@ function PiePanelWrapper({
detectBounds: true,
});
const panelData = queryResponse.data?.payload?.data?.result || [];
const isDarkMode = useIsDarkMode();
let pieChartData: {
label: string;
value: string;
color: string;
record: any;
}[] = [].concat(
...(panelData
.map((d) => {
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
return {
label,
value: d?.values?.[0]?.[1],
record: d,
color:
widget?.customLegendColors?.[label] ||
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
),
};
})
.filter((d) => d !== undefined) as never[]),
);
pieChartData = pieChartData.filter(
(arc) =>
arc.value && !isNaN(parseFloat(arc.value)) && parseFloat(arc.value) > 0,
const pieChartData = useMemo(
() =>
preparePieChartData(queryResponse.data?.payload, {
customLegendColors: widget?.customLegendColors,
colorMap: isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
}),
[queryResponse.data?.payload, widget?.customLegendColors, isDarkMode],
);
let size = 0;

View File

@@ -0,0 +1,185 @@
import { themeColors } from 'constants/theme';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
import { preparePieChartData } from '../preparePieChartData';
const options = { colorMap: themeColors.chartcolors };
/**
* Mirrors a query-range payload: the (possibly collapsed) time-series `result`
* plus the scalar table nested under `newResult` (as getQueryResults produces it).
*/
function makePayload(
result: QueryData[],
tables: QueryDataV3[],
): MetricRangePayloadProps {
return {
data: {
result,
resultType: 'scalar',
newResult: { data: { result: tables, resultType: 'scalar' } },
},
} as MetricRangePayloadProps;
}
function tableEntry(
columns: NonNullable<QueryDataV3['table']>['columns'],
rows: NonNullable<QueryDataV3['table']>['rows'],
overrides: Partial<QueryDataV3> = {},
): QueryDataV3 {
return {
queryName: 'A',
legend: '',
series: null,
list: null,
table: { columns, rows },
...overrides,
} as QueryDataV3;
}
describe('preparePieChartData', () => {
it('renders a slice per value column for a multi-column ClickHouse scalar', () => {
// SELECT count() AS col1, sum(value) AS col2 — the backend collapses the
// time-series result onto col1; the full data lives in the scalar table.
const payload = makePayload(
[
{
metric: {},
queryName: 'A',
legend: '',
values: [[0, '23399927']],
} as QueryData,
],
[
tableEntry(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { col1: 23399927, col2: 588691297 } }],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices).toHaveLength(2);
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['col1', '23399927'],
['col2', '588691297'],
]);
});
it('prefixes the group when multiple value columns are grouped', () => {
const payload = makePayload(
[],
[
tableEntry(
[
{ name: 'env', queryName: 'A', isValueColumn: false, id: 'env' },
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
],
[{ data: { env: 'prod', col1: 10, col2: 20 } }],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices.map((s) => s.label)).toStrictEqual([
'prod · col1',
'prod · col2',
]);
expect(slices[0].record.metric).toStrictEqual({ env: 'prod' });
});
it('drops non-positive and non-numeric values', () => {
const payload = makePayload(
[],
[
tableEntry(
[
{ name: 'col1', queryName: 'A', isValueColumn: true, id: 'col1' },
{ name: 'col2', queryName: 'A', isValueColumn: true, id: 'col2' },
{ name: 'col3', queryName: 'A', isValueColumn: true, id: 'col3' },
],
[{ data: { col1: 5, col2: 0, col3: 'n/a' } }],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices.map((s) => s.label)).toStrictEqual(['col1']);
});
it('keeps the series path for a single value column (grouped panel)', () => {
// One value column → the time-series result is authoritative (one slice per
// group), so existing behaviour is preserved.
const payload = makePayload(
[
{
metric: { 'service.name': 'adservice' },
queryName: 'A',
legend: 'adservice',
values: [[0, '100']],
} as QueryData,
{
metric: { 'service.name': 'cartservice' },
queryName: 'A',
legend: 'cartservice',
values: [[0, '200']],
} as QueryData,
],
[
tableEntry(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
),
],
);
const slices = preparePieChartData(payload, options);
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['adservice', '100'],
['cartservice', '200'],
]);
});
it('uses the legacy series result when there is no scalar table', () => {
const payload = makePayload(
[
{
metric: { 'service.name': 'adservice' },
queryName: 'A',
legend: '{{service.name}}',
values: [[1000, '42']],
} as QueryData,
],
[],
);
const slices = preparePieChartData(payload, options);
expect(slices).toHaveLength(1);
expect(slices[0].value).toBe('42');
});
it('returns no slices for an empty payload', () => {
expect(preparePieChartData(undefined, options)).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,144 @@
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { isNaN } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
export interface PieChartSlice {
label: string;
value: string;
color: string;
record: {
queryName: string;
legend?: string;
/** Group-by labels, used for drilldown; absent when the slice has no group. */
metric?: QueryData['metric'];
};
}
interface PreparePieChartDataOptions {
customLegendColors?: Record<string, string>;
colorMap: Record<string, string>;
}
const colorFor = (
label: string,
{ customLegendColors, colorMap }: PreparePieChartDataOptions,
): string => customLegendColors?.[label] || generateColor(label, colorMap);
const isPositive = (value: string): boolean =>
!!value && !isNaN(parseFloat(value)) && parseFloat(value) > 0;
/**
* Time-series result: one slice per series, value = first datapoint. This is the
* original pie behaviour — kept verbatim (same label/value/colour/record) so
* single-value and grouped panels are unaffected.
*/
function slicesFromSeries(
result: QueryData[],
options: PreparePieChartDataOptions,
): PieChartSlice[] {
return result
.filter((d) => d?.values?.[0]?.[1] !== undefined)
.map((d) => {
const label = getLabelName(d.metric, d.queryName || '', d.legend || '');
return {
label,
value: d.values[0][1],
color: colorFor(label, options),
record: d,
};
});
}
/**
* V5 scalar table: one slice per (row × value column). With more than one value
* column the column name keeps the slices distinct, so a ClickHouse query like
* `count() AS col1, sum() AS col2` renders a slice per column instead of
* collapsing onto the first; group-by columns become the slice label.
*/
function slicesFromTables(
tables: QueryDataV3[],
options: PreparePieChartDataOptions,
): PieChartSlice[] {
const slices: PieChartSlice[] = [];
tables.forEach((entry) => {
const { table } = entry;
if (!table?.columns?.length || !table?.rows?.length) {
return;
}
const valueColumns = table.columns.filter((column) => column.isValueColumn);
if (valueColumns.length === 0) {
return;
}
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
const hasMultipleValueColumns = valueColumns.length > 1;
table.rows.forEach((row) => {
const groupLabel = labelColumns
.map((column) => row.data[column.id || column.name])
.filter((part) => part != null)
.map(String)
.join(', ');
// Drilldown filters by group-by labels; leave it undefined when there
// are none (e.g. a ClickHouse query) so no filterless menu is offered.
const metric = labelColumns.length
? labelColumns.reduce<Record<string, string>>((acc, column) => {
acc[column.name] = String(row.data[column.id || column.name]);
return acc;
}, {})
: undefined;
valueColumns.forEach((column) => {
let label: string;
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
label = groupLabel || entry.legend || entry.queryName || '';
}
slices.push({
label,
value: String(row.data[column.id || column.name]),
color: colorFor(label, options),
record: { queryName: entry.queryName, legend: entry.legend, metric },
});
});
});
});
return slices;
}
/**
* Builds pie slices from a query-range payload, dropping non-positive/non-numeric
* values.
*
* A scalar response with several value columns (e.g. a ClickHouse
* `count() AS col1, sum() AS col2`) collapses to a single series in
* `data.result` — only the first value column survives. The full data is kept in
* the scalar table under `newResult`, so in that case slices are built from the
* table (one per value column). Otherwise the legacy time-series result is used,
* preserving existing behaviour for single-value and grouped panels.
*/
export function preparePieChartData(
payload: MetricRangePayloadProps | undefined,
options: PreparePieChartDataOptions,
): PieChartSlice[] {
const tables = (payload?.data?.newResult?.data?.result || []).filter(
(entry) => entry?.table?.rows?.length,
);
const hasMultipleValueColumns = tables.some(
(entry) =>
(entry.table?.columns || []).filter((column) => column.isValueColumn)
.length > 1,
);
const slices = hasMultipleValueColumns
? slicesFromTables(tables, options)
: slicesFromSeries(payload?.data?.result || [], options);
return slices.filter((slice) => isPositive(slice.value));
}

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