Compare commits

..

10 Commits

Author SHA1 Message Date
Vinícius Lourenço
f050419cff perf(tsconfig): enable incremental & fix large type resolution 2026-07-01 10:28:20 -03:00
Vinícius Lourenço
5ea514b94f chore(tsconfig): explicit path mappings and cleaned includes 2026-07-01 10:28:20 -03: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
160 changed files with 7020 additions and 3807 deletions

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:
@@ -2536,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:
@@ -2737,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:
@@ -5412,6 +5397,9 @@ components:
type: string
id:
type: string
ingestedSamples:
minimum: 0
type: integer
ingestedSeries:
minimum: 0
type: integer
@@ -5424,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
@@ -5444,7 +5432,8 @@ components:
- active
- ingestedSeries
- retainedSeries
- reductionPercent
- ingestedSamples
- retainedSamples
type: object
MetricreductionruletypesGettableReductionRulePreview:
properties:
@@ -5487,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:
@@ -5561,7 +5558,6 @@ components:
- metric
- ingested_volume
- reduced_volume
- reduction
- last_updated
type: string
MetricreductionruletypesUpdatableReductionRule:
@@ -11825,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
@@ -11949,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

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

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

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

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

@@ -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;
/**
@@ -3249,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;
/**
@@ -3995,6 +3977,14 @@ export interface DashboardtypesDashboardPanelRefDTO {
* @type string
*/
dashboardName: string;
/**
* @type array
*/
filterBy?: string[];
/**
* @type array
*/
groupBy?: string[];
/**
* @type string
*/
@@ -6919,6 +6909,11 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
* @type string
*/
id: string;
/**
* @type integer
* @minimum 0
*/
ingestedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6934,10 +6929,10 @@ export interface MetricreductionruletypesGettableReductionRuleDTO {
*/
metricName: string;
/**
* @type number
* @format double
* @type integer
* @minimum 0
*/
reductionPercent: number;
retainedSamples: number;
/**
* @type integer
* @minimum 0
@@ -6996,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
@@ -7056,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 {
@@ -10248,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

View File

@@ -1,5 +1,14 @@
import { MutableRefObject } from 'react';
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
import {
ActiveElement,
Chart,
ChartConfiguration,
ChartData,
ChartEvent,
ChartType,
Color,
TooltipItem,
} from 'chart.js';
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -60,184 +69,189 @@ export const getGraphOptions = (
minTime?: number,
maxTime?: number,
// eslint-disable-next-line sonarjs/cognitive-complexity
): CustomChartOptions => ({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
): CustomChartOptions =>
({
animation: {
duration: animate ? 200 : 0,
},
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: true,
},
plugins: {
...(staticLine
? {
annotation: {
annotations: [
{
type: 'line',
yMin: staticLine.yMin,
yMax: staticLine.yMax,
borderColor: staticLine.borderColor,
borderWidth: staticLine.borderWidth,
label: {
content: staticLine.lineText,
enabled: true,
font: {
size: 10,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
borderWidth: 0,
position: 'start',
backgroundColor: 'transparent',
color: staticLine.textColor,
},
},
],
],
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context: TooltipItem<'line'>[]): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context: TooltipItem<'line'>): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData: TooltipItem<'line'>): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
},
}
: {}),
title: {
display: title !== undefined,
text: title,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
title(context): string | string[] {
const date = dayjs(context[0].parsed.x);
return date
.tz(timezone.value)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
},
label(context): string | string[] {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
}
return label;
},
labelTextColor(labelData): Color {
if (labelData.datasetIndex === nearestDatasetIndex.current) {
return 'rgba(255, 255, 255, 1)';
}
return 'rgba(255, 255, 255, 0.75)';
position: 'custom',
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
return item2.parsed.y - item1.parsed.y;
},
},
position: 'custom',
itemSort(item1, item2): number {
return item2.parsed.y - item1.parsed.y;
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
[dragSelectPluginId]: createDragSelectPluginOptions(
!!onDragSelect,
onDragSelect,
dragSelectColor,
),
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
!!onDragSelect,
currentTheme === 'dark' ? 'white' : 'black',
),
},
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
layout: {
padding: 0,
},
scales: {
x: {
stacked: isStacked,
offset: false,
grid: {
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
color: getGridColor(),
drawTicks: true,
},
adapters: {
date: chartjsAdapter,
},
time: {
unit: xAxisTimeUnit?.unitName || 'minute',
stepSize: xAxisTimeUnit?.stepSize || 1,
displayFormats: {
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
second: DATE_TIME_FORMATS.TIME_SECONDS,
minute: DATE_TIME_FORMATS.TIME,
hour: DATE_TIME_FORMATS.SLASH_SHORT,
day: DATE_TIME_FORMATS.DATE_SHORT,
week: DATE_TIME_FORMATS.DATE_SHORT,
month: DATE_TIME_FORMATS.YEAR_MONTH,
year: DATE_TIME_FORMATS.YEAR_SHORT,
grid: {
display: true,
color: getGridColor(),
},
},
type: 'time',
ticks: { color: getAxisLabelColor(currentTheme) },
...(minTime && {
min: dayjs(minTime).tz(timezone.value).format(),
}),
...(maxTime && {
max: dayjs(maxTime).tz(timezone.value).format(),
}),
},
y: {
stacked: isStacked,
display: true,
grid: {
display: true,
color: getGridColor(),
},
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
ticks: {
color: getAxisLabelColor(currentTheme),
// Include a dollar sign in the ticks
callback(value: number | string): string {
return getYAxisFormattedValue(value.toString(), yAxisUnit);
},
},
},
},
},
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
elements: {
line: {
tension: 0,
cubicInterpolationMode: 'monotone',
},
hoverRadius: 5,
},
},
onClick: (event, element, chart): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
},
onHover: (event, _, chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
point: {
hoverBackgroundColor: (ctx: any): string => {
if (ctx?.element?.options?.borderColor) {
return ctx.element.options.borderColor;
}
return 'rgba(0,0,0,0.1)';
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
hoverRadius: 5,
},
},
onClick: (
event: ChartEvent,
element: ActiveElement[],
chart: Chart,
): void => {
if (onClickHandler) {
onClickHandler(event, element, chart, data);
}
}
},
});
},
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
if (event.native) {
const interactions = chart.getElementsAtEventForMode(
event.native,
'nearest',
{
intersect: false,
},
true,
);
if (interactions[0]) {
nearestDatasetIndex.current = interactions[0].datasetIndex;
}
}
},
}) as CustomChartOptions;
declare module 'chart.js' {
interface TooltipPositionerMap {

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

@@ -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,8 +0,0 @@
.emptyMeterSearch {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}

View File

@@ -1,26 +0,0 @@
import { Empty } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from './EmptyMeterSearch.module.scss';
interface EmptyMeterSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMeterSearch({
hasQueryResult,
}: EmptyMeterSearchProps): JSX.Element {
return (
<div className={styles.emptyMeterSearch}>
<Empty
description={
<Typography.Title level={5}>
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
</Typography.Title>
}
/>
</div>
);
}

View File

@@ -73,6 +73,34 @@
margin-top: 10px;
margin-bottom: 20px;
}
.empty-meter-search {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.time-series-view-panel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 8px !important;
margin: 8px;
}
.time-series-container {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(100%, calc(50% - 8px)), 1fr)
);
gap: 16px;
width: 100%;
height: fit-content;
}
}
}
@@ -85,6 +113,22 @@
padding-bottom: 80px;
}
.meter-time-series-container {
display: flex;
flex-direction: column;
gap: 10px;
.builder-units-filter {
padding: 0 8px;
margin-bottom: 0px !important;
.builder-units-filter-label {
margin-bottom: 0px !important;
font-size: 12px;
}
}
}
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;

View File

@@ -1,18 +0,0 @@
.loadingMeter {
display: flex;
justify-content: center;
align-items: flex-start;
height: 240px;
padding: var(--spacing-12) 0;
}
.loadingMeterContent {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.loadingGif {
height: 72px;
margin-left: calc(-1 * var(--spacing-12));
}

View File

@@ -1,17 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import styles from './MeterLoading.module.scss';
export default function MeterLoading(): JSX.Element {
return (
<div className={styles.loadingMeter}>
<div className={styles.loadingMeterContent}>
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
<Typography>Retrieving your {DataSource.METRICS}</Typography>
</div>
</div>
);
}

View File

@@ -1,31 +0,0 @@
.meterTimeSeriesContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-5);
width: 100%;
:global(.builder-units-filter) {
padding: 0 var(--spacing-4);
margin-bottom: 0 !important;
}
:global(.builder-units-filter-label) {
margin-bottom: 0 !important;
font-size: 12px;
}
}
.timeSeriesContainer {
gap: var(--spacing-8);
width: 100%;
height: 50vh;
max-height: 50vh;
padding-right: 16px;
padding-left: 8px;
}
.timeSeriesViewPanel {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
}

View File

@@ -1,28 +1,27 @@
import { useMemo, useRef } from 'react';
import { useEffect, useMemo } from 'react';
import { useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { isAxiosError } from 'axios';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { useTimezone } from 'providers/Timezone';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { buildMeterChartConfig } from './configBuilder';
import EmptyMeterSearch from './EmptyMeterSearch';
import MeterLoading from './MeterLoading';
import styles from './TimeSeries.module.scss';
import { useTimeSeriesQueries } from './useTimeSeriesQueries';
import { useTimeSeriesTimeManagement } from './useTimeSeriesTimeManagement';
const WIDGET_ID = 'meter-explorer-bar-chart';
interface TimeSeriesProps {
onFetchingStateChange?: (isFetching: boolean) => void;
@@ -33,124 +32,144 @@ function TimeSeries({
onFetchingStateChange,
isCancelled = false,
}: TimeSeriesProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const { stagedQuery, currentQuery } = useQueryBuilder();
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const containerDimensions = useResizeObserver(graphRef);
const {
selectedTime: globalSelectedTime,
maxTime,
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { minTimeScale, maxTimeScale, onDragSelect } =
useTimeSeriesTimeManagement({
globalSelectedTime,
maxTime,
minTime,
});
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
const { responseData, isLoading, isError } = useTimeSeriesQueries({
stagedQuery,
currentQuery,
globalSelectedTime,
maxTime,
minTime,
onFetchingStateChange,
});
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const queryPayloads = useMemo(
() => [stagedQuery || initialQueryMeterWithType],
[stagedQuery],
);
const { showErrorModal } = useErrorModal();
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
payload,
ENTITY_VERSION_V5,
globalSelectedTime,
maxTime,
minTime,
index,
],
queryFn: ({
signal,
}: {
signal?: AbortSignal;
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: payload,
graphType: PANEL_TYPES.BAR,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V5,
undefined,
signal,
),
enabled: !!payload,
retry: (failureCount: number, error: unknown): boolean => {
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
return false;
}
let status: number | undefined;
if (error instanceof APIError) {
status = error.getHttpStatusCode();
} else if (isAxiosError(error)) {
status = error.response?.status;
}
if (status && status >= 400 && status < 500) {
return false;
}
return failureCount < MAX_QUERY_RETRIES;
},
onError: (error: APIError): void => {
showErrorModal(error);
},
})),
);
const isFetching = queries.some((q) => q.isFetching);
useEffect(() => {
onFetchingStateChange?.(isFetching);
}, [isFetching, onFetchingStateChange]);
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
const responseData = useMemo(
() =>
data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
),
[data, isValidToConvertToMs],
);
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
const chartsData = useMemo(() => {
return responseData.map((response, index) => {
const apiResponse = response?.payload;
const config = buildMeterChartConfig({
id: `${WIDGET_ID}-${index}`,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit: yAxisUnit || 'short',
minTimeScale,
maxTimeScale,
});
const chartData = apiResponse ? prepareChartData(apiResponse) : [];
return {
config,
chartData,
hasData: chartData.length > 0 && chartData[0]?.length > 0,
};
});
}, [
responseData,
currentQuery,
yAxisUnit,
isDarkMode,
onDragSelect,
timezone,
minTimeScale,
maxTimeScale,
]);
const hasAnyData = chartsData.some((chart) => chart.hasData);
return (
<div className={styles.meterTimeSeriesContainer}>
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
<div className={styles.timeSeriesContainer} ref={graphRef}>
{!hasMetricSelected && <EmptyMeterSearch />}
<div className="time-series-container">
{!hasMetricSelected && <EmptyMetricsSearch />}
{isCancelled && hasMetricSelected && (
<QueryCancelledPlaceholder subText='Click "Run Query" to load metrics.' />
)}
{isLoading && hasMetricSelected && !isCancelled && <MeterLoading />}
{!isCancelled &&
hasMetricSelected &&
!isLoading &&
!isError &&
!hasAnyData && (
<EmptyMeterSearch hasQueryResult={responseData[0] !== undefined} />
)}
{!isCancelled &&
hasMetricSelected &&
!isLoading &&
!isError &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 &&
chartsData.map(
(chart, index) =>
chart.hasData && (
<div
className={styles.timeSeriesViewPanel}
// oxlint-disable-next-line react/no-array-index-key -- query responses have no stable ID
key={`${WIDGET_ID}-${index}`}
>
<BarChart
config={chart.config}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
data={chart.chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
isStackedBarChart
yAxisUnit={yAxisUnit || 'short'}
timezone={timezone}
/>
</div>
),
)}
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
</div>
</div>
);

View File

@@ -1,117 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
export interface MeterChartConfigProps {
id: string;
isDarkMode: boolean;
currentQuery: Query;
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse?: MetricRangePayloadProps;
timezone: Timezone;
yAxisUnit: string;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildMeterChartConfig({
id,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit,
minTimeScale,
maxTimeScale,
}: MeterChartConfigProps): UPlotConfigBuilder {
const stepIntervals = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
) as Record<string, number>;
const minStepInterval = Object.keys(stepIntervals).length
? Math.min(...Object.values(stepIntervals))
: undefined;
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
tzDate,
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
stepInterval: minStepInterval,
});
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
});
builder.addScale({
scaleKey: 'y',
time: false,
});
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.BAR,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.BAR,
});
if (!apiResponse?.data?.result) {
return builder;
}
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
builder.setBands(getInitialStackedBands(seriesCount));
apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = getLegend(series, currentQuery, baseLabelName);
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping: {},
isDarkMode,
stepInterval: currentStepInterval,
metric: series.metric,
});
});
return builder;
}

View File

@@ -1,146 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useQueries } from 'react-query';
import { isAxiosError } from 'axios';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { SuccessResponse } from 'types/api';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesQueriesProps {
stagedQuery: Query | null;
currentQuery: Query;
globalSelectedTime: Time | CustomTimeType;
maxTime: number;
minTime: number;
onFetchingStateChange?: (isFetching: boolean) => void;
}
interface UseTimeSeriesQueriesResult {
responseData: (SuccessResponse<MetricRangePayloadProps> | undefined)[];
isLoading: boolean;
isError: boolean;
}
export function useTimeSeriesQueries({
stagedQuery,
currentQuery,
globalSelectedTime,
maxTime,
minTime,
onFetchingStateChange,
}: UseTimeSeriesQueriesProps): UseTimeSeriesQueriesResult {
const { showErrorModal } = useErrorModal();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||
aggregateAttribute?.key === 'duration_nano';
const isCountOperator =
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
},
);
return isValid.every(Boolean);
}, [currentQuery]);
const queryPayloads = useMemo(
() => [stagedQuery || initialQueryMeterWithType],
[stagedQuery],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [
REACT_QUERY_KEY.GET_QUERY_RANGE,
payload,
ENTITY_VERSION_V5,
globalSelectedTime,
maxTime,
minTime,
index,
],
queryFn: ({
signal,
}: {
signal?: AbortSignal;
}): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(
{
query: payload,
graphType: PANEL_TYPES.BAR,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
params: {
dataSource: DataSource.METRICS,
},
},
ENTITY_VERSION_V5,
undefined,
signal,
),
enabled: !!payload,
retry: (failureCount: number, error: unknown): boolean => {
if (isAxiosError(error) && error.code === 'ERR_CANCELED') {
return false;
}
let status: number | undefined;
if (error instanceof APIError) {
status = error.getHttpStatusCode();
} else if (isAxiosError(error)) {
status = error.response?.status;
}
if (status && status >= 400 && status < 500) {
return false;
}
return failureCount < MAX_QUERY_RETRIES;
},
onError: (error: APIError): void => {
showErrorModal(error);
},
})),
);
const isFetching = queries.some((q) => q.isFetching);
useEffect(() => {
onFetchingStateChange?.(isFetching);
}, [isFetching, onFetchingStateChange]);
const responseData = useMemo(() => {
const data = queries.map(({ data }) => data) ?? [];
return data.map((datapoint) =>
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
);
}, [queries, isValidToConvertToMs]);
const isLoading = queries.some((q) => q.isLoading);
const isError = queries.some((q) => q.isError);
return {
responseData,
isLoading,
isError,
};
}

View File

@@ -1,102 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { UpdateTimeInterval } from 'store/actions';
import { getTimeRange } from 'utils/getTimeRange';
interface UseTimeSeriesTimeManagementProps {
globalSelectedTime: Time | CustomTimeType;
maxTime: number;
minTime: number;
}
interface UseTimeSeriesTimeManagementResult {
minTimeScale: number | undefined;
maxTimeScale: number | undefined;
onDragSelect: (start: number, end: number) => void;
}
export function useTimeSeriesTimeManagement({
globalSelectedTime,
maxTime,
minTime,
}: UseTimeSeriesTimeManagementProps): UseTimeSeriesTimeManagementResult {
const dispatch = useDispatch();
const urlQuery = useUrlQuery();
const location = useLocation();
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
useEffect((): void => {
const { startTime, endTime } = getTimeRange();
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [maxTime, minTime, globalSelectedTime]);
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
if (startTimestamp !== endTimestamp) {
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
}
const { maxTime, minTime } = GetMinMax('custom', [
startTimestamp,
endTimestamp,
]);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
urlQuery.delete(QueryParams.relativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
},
[dispatch, location.pathname, urlQuery],
);
const handleBackNavigation = useCallback((): void => {
const searchParams = new URLSearchParams(window.location.search);
const startTime = searchParams.get(QueryParams.startTime);
const endTime = searchParams.get(QueryParams.endTime);
const relativeTime = searchParams.get(
QueryParams.relativeTime,
) as CustomTimeType;
if (relativeTime) {
dispatch(UpdateTimeInterval(relativeTime));
} else if (startTime && endTime && startTime !== endTime) {
dispatch(
UpdateTimeInterval('custom', [
parseInt(getTimeString(startTime), 10),
parseInt(getTimeString(endTime), 10),
]),
);
}
}, [dispatch]);
useEffect(() => {
window.addEventListener('popstate', handleBackNavigation);
return (): void => {
window.removeEventListener('popstate', handleBackNavigation);
};
}, [handleBackNavigation]);
return {
minTimeScale,
maxTimeScale,
onDragSelect,
};
}

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,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));
}

View File

@@ -30,7 +30,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { stackSeries } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { isEmpty } from 'lodash-es';
@@ -58,7 +57,6 @@ function TimeSeriesView({
dataSource,
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -67,23 +65,11 @@ function TimeSeriesView({
const location = useLocation();
const { currentQuery } = useQueryBuilder();
const rawChartData = useMemo(
const chartData = useMemo(
() => getUPlotChartData(data?.payload),
[data?.payload],
);
const { chartData, stackedBands } = useMemo(() => {
if (!stackBarChart || !rawChartData || rawChartData.length < 2) {
return { chartData: rawChartData, stackedBands: null };
}
const noSeriesHidden = (): boolean => false;
const { data: stacked, bands } = stackSeries(
rawChartData as uPlot.AlignedData,
noSeriesHidden,
);
return { chartData: stacked, stackedBands: bands };
}, [rawChartData, stackBarChart]);
useEffect(() => {
if (data?.payload) {
setWarning?.(data?.warning);
@@ -203,7 +189,7 @@ function TimeSeriesView({
const { timezone } = useTimezone();
const baseChartOptions = getUPlotChartOptions({
const chartOptions = getUPlotChartOptions({
id: 'time-series-explorer',
onDragSelect,
yAxisUnit: yAxisUnit || '',
@@ -236,14 +222,6 @@ function TimeSeriesView({
},
});
const chartOptions = useMemo(
() =>
stackedBands
? { ...baseChartOptions, bands: stackedBands }
: baseChartOptions,
[baseChartOptions, stackedBands],
);
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
@@ -304,7 +282,6 @@ interface TimeSeriesViewProps {
dataSource: DataSource;
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
}
TimeSeriesView.defaultProps = {
@@ -313,7 +290,6 @@ TimeSeriesView.defaultProps = {
error: undefined,
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
};
export default TimeSeriesView;

View File

@@ -24,6 +24,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { getGraphType } from 'utils/getGraphType';
/**
* @deprecated V1-only. V2 dashboards seed alerts from a panel via
* `useCreateAlertFromPanel` / `buildCreateAlertUrl`
* (pages/DashboardPageV2/.../Panel). Do not use in new code.
*/
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);

View File

@@ -0,0 +1,43 @@
import { SquareArrowOutUpRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { ReactNode } from 'react';
import styles from './ConfigActions.module.scss';
interface ConfigActionRowProps {
/** Leading glyph for the action. */
icon: ReactNode;
label: string;
onClick: () => void;
testId?: string;
}
/**
* One row in the config pane's "Actions" list — a cross-page navigation link
* (leading icon, label, trailing external-link affordance). The whole row is the
* click target.
*/
function ConfigActionRow({
icon,
label,
onClick,
testId,
}: ConfigActionRowProps): JSX.Element {
return (
<Button
type="button"
variant="outlined"
color="secondary"
className={styles.row}
data-testid={testId}
onClick={onClick}
prefix={<span className={styles.icon}>{icon}</span>}
suffix={<SquareArrowOutUpRight size={14} />}
>
<Typography.Text className={styles.label}>{label}</Typography.Text>
</Button>
);
}
export default ConfigActionRow;

View File

@@ -0,0 +1,57 @@
/* The "Actions" group: a list of cross-page navigation links, visually separated
from the collapsible config sections above by the same hairline divider. */
.divider {
height: 1px;
background: var(--l2-border);
margin: 18px 0;
}
.container {
padding: 0 16px;
}
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
.list {
display: flex;
flex-direction: column;
}
/* A navigation-link row: leading icon, label, trailing external-link affordance. */
.row {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 44px;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
&:hover {
background: color-mix(in srgb, var(--bg-vanilla-100) 6%, transparent);
}
}
.icon {
display: grid;
place-items: center;
flex: none;
color: var(--l2-foreground);
}
.label {
flex: 1;
text-align: left;
}

View File

@@ -0,0 +1,51 @@
import { Bell } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
import ConfigActionRow from './ConfigActionRow';
import styles from './ConfigActions.module.scss';
interface ConfigActionsProps {
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
panel: DashboardtypesPanelDTO;
panelId: string;
}
/**
* The "Actions" group at the foot of the config pane: cross-page navigation links,
* kept distinct from the collapsible config sections above. Each link is gated by the
* panel kind's capabilities; the whole group hides when none apply.
*/
function ConfigActions({
panel,
panelId,
}: ConfigActionsProps): JSX.Element | null {
const createAlert = useCreateAlertFromPanel();
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
// Only kinds whose query can seed an alert offer this today; mirror the panel
// menu's create-alert capability.
if (!actions.createAlert) {
return null;
}
return (
<>
<div className={styles.divider} />
<div className={styles.container}>
<span className={styles.eyebrow}>Actions</span>
<div className={styles.list}>
<ConfigActionRow
testId="panel-editor-v2-create-alert"
icon={<Bell size={14} />}
label="Create alert"
onClick={(): void => createAlert(panel, panelId)}
/>
</div>
</div>
</>
);
}
export default ConfigActions;

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigActions from '../ConfigActions';
const mockCreateAlert = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: jest.fn(() => mockCreateAlert),
}),
);
function makePanel(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('ConfigActions', () => {
beforeEach(() => jest.clearAllMocks());
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
const user = userEvent.setup();
const panel = makePanel('signoz/TimeSeriesPanel');
render(<ConfigActions panel={panel} panelId="panel-1" />);
const row = screen.getByTestId('panel-editor-v2-create-alert');
expect(row).toHaveTextContent('Create alert');
await user.click(row);
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
});
it('renders nothing for a kind that cannot seed an alert', () => {
const { container } = render(
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
);
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -1,20 +1,22 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from '../hooks/useLegendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';
import styles from './ConfigPane.module.scss';
import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
panelKind: PanelKind;
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
@@ -32,6 +34,12 @@ interface ConfigPaneProps {
tableColumns: TableColumnOption[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
* The draft panel and its id — the "Actions" group seeds cross-page links
* (Create alert) from the current query.
*/
panel: DashboardtypesPanelDTO;
panelId: string;
}
/**
@@ -41,7 +49,6 @@ interface ConfigPaneProps {
* generically via the section registry — only sections with a built editor appear.
*/
function ConfigPane({
panelKind,
spec,
onChangeSpec,
onChangePanelKind,
@@ -49,7 +56,10 @@ function ConfigPane({
legendSeries,
tableColumns,
stepInterval,
panel,
panelId,
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
@@ -114,6 +124,8 @@ function ConfigPane({
</div>
</>
)}
<ConfigActions panel={panel} panelId={panelId} />
</div>
);
}

View File

@@ -1,9 +1,20 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import ConfigPane from '../ConfigPane';
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
}),
);
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -19,13 +30,14 @@ function renderConfigPane(
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
): React.ComponentProps<typeof ConfigPane> {
const props: React.ComponentProps<typeof ConfigPane> = {
panelKind: 'signoz/TimeSeriesPanel',
spec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
legendSeries: [],
tableColumns: [],
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
panelId: 'panel-1',
...overrides,
};
render(<ConfigPane {...props} />);
@@ -63,4 +75,28 @@ describe('ConfigPane', () => {
screen.getByTestId('config-section-formatting-&-units'),
).toBeInTheDocument();
});
it('renders the Actions group for a create-alert-capable panel', () => {
// renderConfigPane defaults to a TimeSeries panel, which can seed an alert.
renderConfigPane();
expect(screen.getByText('Actions')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-create-alert'),
).toBeInTheDocument();
});
it('omits the create-alert action for a kind that cannot seed an alert', () => {
// Table panels can't seed alerts → the Actions group hides its row. Only the
// panel passed to ConfigActions needs the kind; sections are asserted elsewhere.
const panel = {
kind: 'Panel',
spec: { ...spec(), plugin: { kind: 'signoz/TablePanel', spec: {} } },
} as DashboardtypesPanelDTO;
renderConfigPane({ panel });
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
});
});

View File

@@ -71,10 +71,8 @@ function PreviewPane({
<div className={styles.container}>
<div className={styles.surface}>
<PanelHeader
name={panel.spec.display.name}
description={panel.spec.display.description}
panelId={panelId}
panelKind={panel.spec.plugin.kind}
panel={panel}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}

View File

@@ -242,7 +242,8 @@ function PanelEditorContainer({
className={styles.right}
>
<ConfigPane
panelKind={draft.spec.plugin.kind}
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}

View File

@@ -41,10 +41,6 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const name = panel.spec.display.name;
const description = panel.spec.display?.description;
const fullKind = panel.spec.plugin.kind;
// A per-panel time preference is surfaced as a header pill. `visualization` is
// common to every plugin-spec variant — localized cast reads it without
// narrowing on kind.
@@ -55,7 +51,8 @@ function Panel({
)?.visualization?.timePreference;
const timeLabel = panelTimePreferenceLabel(timePreference);
const panelDefinition = getPanelDefinition(fullKind);
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
@@ -77,10 +74,8 @@ function Panel({
data-panel-visible={isVisible ? 'true' : 'false'}
>
<PanelHeader
name={name}
description={description}
panelId={panelId}
panelKind={fullKind}
panel={panel}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}

View File

@@ -1,17 +1,17 @@
import { EllipsisVertical } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import type { PanelActionsConfig } from '../Panel';
import { usePanelActionItems } from './usePanelActionItems';
import styles from './PanelActionsMenu.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
interface PanelActionsMenuProps {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`);*/
panelKind: PanelKind;
/** The panel itself — its query seeds "Create Alerts". */
panel: DashboardtypesPanelDTO;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
@@ -23,12 +23,12 @@ interface PanelActionsMenuProps {
*/
function PanelActionsMenu({
panelId,
panelKind,
panel,
panelActions,
}: PanelActionsMenuProps): JSX.Element | null {
const { items, deleteConfirm } = usePanelActionItems({
panelId,
panelKind,
panel,
panelActions,
});

View File

@@ -1,10 +1,10 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { ROLES } from 'types/roles';
import type { DashboardSection } from '../../../../utils';
import { useDashboardStore } from '../../../../store/useDashboardStore';
import { usePanelActionItems } from '../usePanelActionItems';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
const mockOpenEditor = jest.fn();
jest.mock(
@@ -29,6 +29,11 @@ jest.mock('../../hooks/useClonePanel', () => ({
useClonePanel: (): jest.Mock => mockClonePanel,
}));
const mockCreateAlert = jest.fn();
jest.mock('../../hooks/useCreateAlertFromPanel', () => ({
useCreateAlertFromPanel: (): jest.Mock => mockCreateAlert,
}));
// Role is the only thing read off the app context; useComponentPermission runs
// for real so the tests exercise the actual role → permission mapping.
let mockRole: ROLES = 'ADMIN';
@@ -55,9 +60,20 @@ const TWO_TITLED_SECTIONS = [section(0, 'Overview'), section(1, 'Latency')];
// Index 0 is the untitled root (free-flow) section; index 1 is a titled section.
const TITLED_WITH_ROOT = [section(0, undefined), section(1, 'Latency')];
// Minimal panel — only its presence gates "Create Alerts"; the query→URL
// translation it drives is covered by buildCreateAlertUrl's own tests.
const mockPanel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const baseArgs = {
panelId: 'panel-1',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panel: mockPanel,
panelActions: { currentLayoutIndex: 0, sections: TWO_TITLED_SECTIONS },
};
@@ -115,29 +131,18 @@ describe('usePanelActionItems', () => {
]);
});
it('unknown panel kind hides all kind-gated actions (incl. clone), keeping only move/delete', () => {
const { result } = renderHook(() =>
// A kind with no registered definition — exercises the "unsupported kind"
// branch. Clone is kind-gated (needs the kind to declare actions.clone),
// so it drops too; only the kind-agnostic layout actions remain.
usePanelActionItems({
...baseArgs,
panelKind: 'signoz/UnsupportedPanel' as PanelKind,
}),
);
expect(itemKeys(result.current)).toStrictEqual([
'move',
'divider',
'delete-panel',
]);
});
it('read-only dashboard keeps only View (V1 parity)', () => {
it('read-only dashboard keeps View and Create Alerts (V1 parity: both survive a lock)', () => {
useDashboardStore.setState({ isEditable: false });
const { result } = renderHook(() =>
usePanelActionItems({ ...baseArgs, panelActions: undefined }),
);
expect(itemKeys(result.current)).toStrictEqual(['view-panel']);
// Create Alerts opens a new tab and never mutates the dashboard, so it
// isn't gated on edit access — matching V1's locked-dashboard menu.
expect(itemKeys(result.current)).toStrictEqual([
'view-panel',
'divider',
'create-alert',
]);
});
it('move is disabled when there is no other titled section to move to', () => {
@@ -259,18 +264,26 @@ describe('usePanelActionItems', () => {
});
});
it('not-yet-implemented actions (view/create-alert) fire the placeholder alert with the feature name', () => {
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
['view-panel', 'create-alert'].forEach((key) => {
const item = result.current.items.find((i) => 'key' in i && i.key === key);
(item as { onClick: () => void }).onClick();
});
const view = result.current.items.find(
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
expect(alertSpy).toHaveBeenCalledTimes(2);
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
expect(alertSpy).toHaveBeenCalledWith('Create Alerts option clicked');
alertSpy.mockRestore();
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
});

View File

@@ -10,6 +10,7 @@ import {
Trash2,
} from '@signozhq/icons';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import useComponentPermission from 'hooks/useComponentPermission';
import {
type ConfirmableAction,
@@ -23,13 +24,13 @@ import { useAppContext } from 'providers/App/App';
import type { DashboardSection } from '../../../utils';
import type { PanelActionsConfig } from '../Panel';
import { useClonePanel } from '../hooks/useClonePanel';
import { useCreateAlertFromPanel } from '../hooks/useCreateAlertFromPanel';
import { useDeletePanel } from '../hooks/useDeletePanel';
import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { PANEL_ACTION_META } from './panelActionMeta';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
@@ -103,8 +104,8 @@ function buildMoveItems({
interface UsePanelActionItemsArgs {
panelId: string;
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); */
panelKind: PanelKind;
/** The panel itself — its query seeds the "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Layout context for move/delete — absent outside editable mode. */
panelActions?: PanelActionsConfig;
}
@@ -128,9 +129,10 @@ export interface PanelActionItems {
*/
export function usePanelActionItems({
panelId,
panelKind,
panel,
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
const { user } = useAppContext();
const [canEditWidget, canMove, canDelete] = useComponentPermission(
[
@@ -143,6 +145,7 @@ export function usePanelActionItems({
);
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
@@ -151,7 +154,7 @@ export function usePanelActionItems({
const deletePanel = useDeletePanel({ sections });
const clonePanel = useClonePanel({ sections });
const kindActions = getPanelDefinition(panelKind)?.actions;
const panelCapabilities = getPanelDefinition(panelKind).actions;
// Delete runs on confirm, not on click — the menu item opens a prompt.
const deleteConfirm = useConfirmableAction(
@@ -170,7 +173,7 @@ export function usePanelActionItems({
const items = useMemo<MenuItem[]>(() => {
const panelGroup: MenuItem[] = [];
if (kindActions?.view) {
if (panelCapabilities.view) {
panelGroup.push({
key: 'view-panel',
label: 'View',
@@ -178,7 +181,7 @@ export function usePanelActionItems({
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && kindActions?.edit) {
if (isEditable && canEditWidget && panelCapabilities.edit) {
panelGroup.push({
key: 'edit-panel',
label: 'Edit panel',
@@ -188,7 +191,7 @@ export function usePanelActionItems({
}
// Clone needs the section context (source spec + dimensions) to place the
// copy, so — unlike Edit — it requires panelActions.
if (isEditable && canEditWidget && panelActions && kindActions?.clone) {
if (isEditable && canEditWidget && panelActions && panelCapabilities.clone) {
panelGroup.push({
key: 'clone-panel',
label: 'Clone',
@@ -202,7 +205,7 @@ export function usePanelActionItems({
}
const dataGroup: MenuItem[] = [];
if (kindActions?.download) {
if (panelCapabilities.download) {
dataGroup.push({
key: 'download-panel',
label: 'Download as CSV',
@@ -210,12 +213,15 @@ export function usePanelActionItems({
onClick: (): void => notImplementedYet('Download'),
});
}
if (isEditable && kindActions?.createAlert) {
// Seeding an alert opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on `isEditable` (V1 parity: available
// on locked dashboards too).
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
icon: <Bell size={14} />,
onClick: (): void => notImplementedYet('Create Alerts'),
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -252,11 +258,13 @@ export function usePanelActionItems({
canEditWidget,
canMove,
canDelete,
kindActions,
panelCapabilities,
panel,
panelActions,
sections,
panelId,
openPanelEditor,
createAlert,
movePanel,
clonePanel,
requestDelete,

View File

@@ -1,7 +1,10 @@
import { useMemo } from 'react';
import { Info, Loader } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesPanelDTO,
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import type { PanelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
@@ -14,15 +17,12 @@ import {
panelStatusFromWarning,
} from '../PanelStatus/utils';
import styles from './PanelHeader.module.scss';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
name: string;
description?: string;
panelId: string;
/** Full plugin kind — drives kind-gated menu actions. */
panelKind: PanelKind;
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Background refresh in flight — shows a spinner without blinking the chart. */
isFetching: boolean;
/** Latest query error — surfaced as a header error indicator. */
@@ -49,10 +49,8 @@ interface PanelHeaderProps {
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
name,
description,
panelId,
panelKind,
panel,
isFetching,
error,
warning,
@@ -63,6 +61,8 @@ function PanelHeader({
onSearchChange,
hideActions,
}: PanelHeaderProps): JSX.Element {
const name = panel.spec.display.name;
const description = panel.spec.display.description;
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
const warningDetail = useMemo(
@@ -116,7 +116,7 @@ function PanelHeader({
{!hideActions && (
<PanelActionsMenu
panelId={panelId}
panelKind={panelKind}
panel={panel}
panelActions={panelActions}
/>
)}

View File

@@ -1,11 +1,11 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { ReactElement } from 'react';
import type { Warning } from 'types/api';
import PanelHeader from '../PanelHeader/PanelHeader';
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
// Status indicators use a radix tooltip, which needs a TooltipProvider ancestor
// (supplied globally by AppLayout at runtime).
@@ -22,9 +22,26 @@ jest.mock(
},
);
// The header reads its name/description/kind off the panel itself.
function makePanel(overrides?: {
name?: string;
description?: string;
}): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: {
name: overrides?.name ?? 'My panel',
description: overrides?.description,
},
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
name: 'My panel',
panelKind: 'signoz/TimeSeriesPanel' as PanelKind,
panel: makePanel(),
panelId: 'panel-1',
isFetching: false,
};
@@ -44,7 +61,10 @@ describe('PanelHeader title and description', () => {
it('shows the description info icon when a description is provided', () => {
renderWithProvider(
<PanelHeader {...baseProps} description="What this panel measures" />,
<PanelHeader
{...baseProps}
panel={makePanel({ description: 'What this panel measures' })}
/>,
);
expect(screen.getByTestId('panel-header-info-icon')).toBeInTheDocument();
});

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useCreateAlertFromPanel } from '../useCreateAlertFromPanel';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
// The V5→V1 query→URL translation is covered by buildCreateAlertUrl's own tests;
// stub it so this asserts only the hook's side effects (analytics + navigation).
jest.mock('../../utils/buildCreateAlertUrl', () => ({
buildCreateAlertUrl: (): string => '/alerts/new?composite=1',
}));
const mockLogEvent = logEvent as jest.Mock;
const panel = {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
describe('useCreateAlertFromPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
useDashboardStore.setState({ dashboardId: 'dash-1' });
});
it('opens the seeded alert builder in a new tab', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts/new?composite=1', {
newTab: true,
});
});
it('logs the create-alert action with panel and dashboard context (V1 parity)', () => {
const { result } = renderHook(() => useCreateAlertFromPanel());
result.current(panel, 'panel-1');
expect(mockLogEvent).toHaveBeenCalledWith(
'Dashboard Detail: Panel action',
expect.objectContaining({
action: 'createAlerts',
panelType: PANEL_TYPES.TIME_SERIES,
dashboardId: 'dash-1',
widgetId: 'panel-1',
}),
);
});
});

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { buildCreateAlertUrl } from '../utils/buildCreateAlertUrl';
/**
* Returns a callback that opens the alert builder in a new tab, seeded from a
* panel's query, and logs the action — mirroring V1's `useCreateAlerts`
* ('dashboardView' caller). The panel is supplied at call time so the callback
* stays stable across panels (and the dashboard's react-query refetches).
*/
export function useCreateAlertFromPanel(): (
panel: DashboardtypesPanelDTO,
panelId: string,
) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panel: DashboardtypesPanelDTO, panelId: string): void => {
void logEvent('Dashboard Detail: Panel action', {
action: 'createAlerts',
panelType: PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
dashboardId,
widgetId: panelId,
queryType: getPanelQueryType(panel),
});
safeNavigate(buildCreateAlertUrl(panel), { newTab: true });
},
[dashboardId, safeNavigate],
);
}

View File

@@ -0,0 +1,103 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { buildCreateAlertUrl } from '../buildCreateAlertUrl';
// The V5→V1 translation has its own coverage; stub it so this asserts only the
// URL assembly (params, encoding, unit) buildCreateAlertUrl owns.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
fromPerses: jest.fn(),
}),
);
const mockFromPerses = fromPerses as jest.Mock;
const translatedQuery: Query = {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
id: 'q1',
};
function makePanel(
overrides?: Partial<{ unit: string; queries: unknown[] }>,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: overrides?.unit ? { formatting: { unit: overrides.unit } } : {},
},
queries: overrides?.queries ?? [{ some: 'query' }],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('buildCreateAlertUrl', () => {
beforeEach(() => {
mockFromPerses.mockReset();
mockFromPerses.mockReturnValue({ ...translatedQuery });
});
function parse(url: string): URLSearchParams {
expect(url.startsWith(`${ROUTES.ALERTS_NEW}?`)).toBe(true);
return new URLSearchParams(url.slice(url.indexOf('?') + 1));
}
it('translates the panel queries with the mapped panel type', () => {
const panel = makePanel();
buildCreateAlertUrl(panel);
expect(mockFromPerses).toHaveBeenCalledWith(
panel.spec.queries,
PANEL_TYPES.TIME_SERIES,
);
});
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('encodes the translated query as the compositeQuery param', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const raw = params.get(QueryParams.compositeQuery);
expect(raw).toBeTruthy();
const decoded = JSON.parse(decodeURIComponent(raw as string));
expect(decoded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(decoded.id).toBe('q1');
});
it('carries the panel formatting unit onto the alert query when set', () => {
const params = parse(buildCreateAlertUrl(makePanel({ unit: 'bytes' })));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBe('bytes');
});
it('leaves the query unit unset when the panel has no formatting unit', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
const decoded = JSON.parse(
decodeURIComponent(params.get(QueryParams.compositeQuery) as string),
);
expect(decoded.unit).toBeUndefined();
});
});

View File

@@ -0,0 +1,54 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelPluginDTO,
} from 'api/generated/services/sigNoz.schemas';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/NumberPanel':
case 'signoz/PieChartPanel':
return plugin.spec.formatting?.unit;
default:
return undefined;
}
}
/**
* Builds the `/alerts/new` URL that seeds the alert builder from a panel's query,
* mirroring V1's `useCreateAlerts`: the panel's V5 queries are translated to the
* V1 `Query` the alert page reads from `compositeQuery`, tagged with the panel
* type, entity version, and a `dashboards` source.
*
* Unlike V1 there is no `/substitute_vars` round-trip — V2 has no query-variable
* plumbing yet, so any dashboard-variable references travel through verbatim.
*/
export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
if (unit) {
query.unit = unit;
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
}

View File

@@ -0,0 +1,67 @@
import { renderHook } from '@testing-library/react';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { selectResolvedVariables } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useResolvedVariables } from '../useResolvedVariables';
// A text variable is the simplest envelope (no list plugin); the builder's full
// type/value matrix is covered in buildVariablesPayload.test.ts. The envelope is
// cast at the boundary — its kind discriminant is the literal 'TextVariable'.
function textVariable(name: string, value: string): DashboardtypesVariableDTO {
return {
kind: 'TextVariable',
spec: { name, value, display: { name } },
} as unknown as DashboardtypesVariableDTO;
}
function dashboard(
id: string,
variables: DashboardtypesVariableDTO[],
): DashboardtypesGettableDashboardV2DTO {
return {
id,
spec: { variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('useResolvedVariables', () => {
afterEach(() => {
useDashboardStore.setState({ variableValues: {}, resolvedVariables: {} });
});
it('publishes the resolved V5 payload for the dashboard to the store', () => {
renderHook(() =>
useResolvedVariables(dashboard('d1', [textVariable('env', 'prod')])),
);
expect(
selectResolvedVariables('d1')(useDashboardStore.getState()),
).toStrictEqual({ env: { type: 'text', value: 'prod' } });
});
it('reflects the runtime selection over the configured default', () => {
useDashboardStore
.getState()
.setVariableValues('d2', { env: { value: 'staging', allSelected: false } });
renderHook(() =>
useResolvedVariables(dashboard('d2', [textVariable('env', 'prod')])),
);
expect(
selectResolvedVariables('d2')(useDashboardStore.getState()),
).toStrictEqual({ env: { type: 'text', value: 'staging' } });
});
it('publishes an empty payload when the dashboard has no variables', () => {
renderHook(() => useResolvedVariables(dashboard('d3', [])));
expect(
selectResolvedVariables('d3')(useDashboardStore.getState()),
).toStrictEqual({});
});
});

View File

@@ -17,6 +17,8 @@ import type { PanelPagination, PanelQueryData } from '../queryV5/types';
import { getRawResults } from '../queryV5/v5ResponseData';
import { getBuilderQueries } from '../Panels/utils/getBuilderQueries';
import { PANEL_KIND_TO_PANEL_TYPE } from '../Panels/types/panelKind';
import { selectResolvedVariables } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolvePanelTimeWindow } from './resolvePanelTimeWindow';
import { useGetQueryRangeV5 } from './useGetQueryRangeV5';
@@ -65,8 +67,9 @@ export interface UsePanelQueryResult {
/**
* Fetches query-range data for a V2 panel over the pure-V5 contract: builds the request DTO
* from the panel's perses queries (no V1 `Query` intermediary), reads global time from Redux,
* and posts via `useGetQueryRangeV5`. Variable substitution is deferred until V2 has its own
* variable plumbing. Renderers consume the raw response through the `queryV5` prep utils.
* substitutes the dashboard's resolved variable values (published to the store by
* `useResolvedVariables`), and posts via `useGetQueryRangeV5`. Renderers consume the raw
* response through the `queryV5` prep utils.
*/
export function usePanelQuery({
panel,
@@ -105,6 +108,11 @@ export function usePanelQuery({
minTime,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
// Substituted into the request and keyed into the cache so a selection change refetches.
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
// `visualization` exists only on variants that declare it — read via `in` narrowing over the
// generated union (no cast). `fillSpans` (TimeSeries/Bar only) → formatOptions.fillGaps.
const pluginSpec = panel.spec.plugin.spec;
@@ -141,8 +149,19 @@ export function usePanelQuery({
endMs,
fillGaps,
pagination: isPaginated ? { offset, limit: pageSize } : undefined,
variables,
}),
[queries, panelType, startMs, endMs, fillGaps, isPaginated, offset, pageSize],
[
queries,
panelType,
startMs,
endMs,
fillGaps,
isPaginated,
offset,
pageSize,
variables,
],
);
const legendMap = useMemo(() => extractLegendMap(queries), [queries]);
@@ -167,6 +186,8 @@ export function usePanelQuery({
// Each page is its own cache entry (0/default for non-paged kinds).
offset,
pageSize,
// Variable selection changes the request, so it must re-key the cache (refetch).
variables,
],
[
panelId,
@@ -182,6 +203,7 @@ export function usePanelQuery({
queries,
offset,
pageSize,
variables,
],
);

View File

@@ -0,0 +1,42 @@
import { useEffect, useMemo } from 'react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters';
import { buildVariablesPayload } from '../queryV5/buildVariablesPayload';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Resolves the dashboard's variable selection into the V5 query payload and
* publishes it to the store, so `usePanelQuery` reads it by dashboardId without
* the spec being threaded through the panel tree (the `setEditContext` pattern).
*
* Definitions come from the spec; values come from the runtime selection (seeded
* by the variable bar). Re-publishes whenever either changes, which re-keys the
* panel queries and triggers a refetch with the new values.
*/
export function useResolvedVariables(
dashboard: DashboardtypesGettableDashboardV2DTO,
): void {
const dashboardId = dashboard.id ?? '';
const definitions = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setResolvedVariables = useDashboardStore((s) => s.setResolvedVariables);
const resolved = useMemo(
() => buildVariablesPayload(definitions, selection),
[definitions, selection],
);
useEffect(() => {
if (!dashboardId) {
return;
}
setResolvedVariables(dashboardId, resolved);
}, [dashboardId, resolved, setResolvedVariables]);
}

View File

@@ -7,6 +7,7 @@ import { useAppContext } from 'providers/App/App';
import DashboardPageToolbar from './DashboardPageToolbar';
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
import { useResolvedVariables } from './hooks/useResolvedVariables';
import { useDashboardStore } from './store/useDashboardStore';
import styles from './DashboardContainer.module.scss';
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
@@ -50,6 +51,10 @@ function DashboardContainer({
setEditContext,
]);
// Resolve the variable selection into the V5 query payload and publish it to
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;

View File

@@ -0,0 +1,103 @@
import {
emptyVariableFormModel,
type VariableFormModel,
type VariableType,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../../VariablesBar/selectionTypes';
import { buildVariablesPayload } from '../buildVariablesPayload';
function variable(
name: string,
type: VariableType,
overrides: Partial<VariableFormModel> = {},
): VariableFormModel {
return { ...emptyVariableFormModel(), name, type, ...overrides };
}
describe('buildVariablesPayload', () => {
it('returns an empty map when there are no definitions', () => {
expect(buildVariablesPayload([], {})).toStrictEqual({});
});
it('maps each UI variable type to its V5 wire type', () => {
const definitions = [
variable('q', 'QUERY'),
variable('c', 'CUSTOM'),
variable('t', 'TEXT'),
variable('d', 'DYNAMIC'),
];
const selection: VariableSelectionMap = {
q: { value: 'a', allSelected: false },
c: { value: 'b', allSelected: false },
t: { value: 'c', allSelected: false },
d: { value: 'e', allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
q: { type: 'query', value: 'a' },
c: { type: 'custom', value: 'b' },
t: { type: 'text', value: 'c' },
d: { type: 'dynamic', value: 'e' },
});
});
it('passes a multi-select array value through verbatim', () => {
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
const selection: VariableSelectionMap = {
svc: { value: ['a', 'b'], allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
svc: { type: 'query', value: ['a', 'b'] },
});
});
it('collapses a multi-select dynamic ALL selection to the __all__ sentinel', () => {
const definitions = [variable('pod', 'DYNAMIC', { multiSelect: true })];
const selection: VariableSelectionMap = {
pod: { value: null, allSelected: true },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
pod: { type: 'dynamic', value: '__all__' },
});
});
it('does NOT collapse a query ALL selection — it sends the full value array', () => {
const definitions = [variable('svc', 'QUERY', { multiSelect: true })];
const selection: VariableSelectionMap = {
svc: { value: ['a', 'b'], allSelected: true },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({
svc: { type: 'query', value: ['a', 'b'] },
});
});
it('falls back to a text variable configured value when unselected', () => {
const definitions = [variable('env', 'TEXT', { textValue: 'prod' })];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
env: { type: 'text', value: 'prod' },
});
});
it('falls back to a list variable configured default when unselected', () => {
const definitions = [
variable('region', 'QUERY', {
defaultValue: { value: 'us-east' },
} as unknown as Partial<VariableFormModel>),
];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({
region: { type: 'query', value: 'us-east' },
});
});
it('omits a variable with no selection and no default', () => {
const definitions = [variable('q', 'QUERY')];
expect(buildVariablesPayload(definitions, {})).toStrictEqual({});
});
it('omits an unnamed variable', () => {
const definitions = [variable('', 'QUERY')];
const selection: VariableSelectionMap = {
'': { value: 'x', allSelected: false },
};
expect(buildVariablesPayload(definitions, selection)).toStrictEqual({});
});
});

View File

@@ -6,6 +6,7 @@ import type {
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
@@ -202,11 +203,13 @@ export interface BuildQueryRangeRequestArgs {
fillGaps?: boolean;
/** Server-side paging for raw/list panels, written onto the builder queries' `offset`/`limit`. */
pagination?: { offset: number; limit: number };
/** Runtime variable values (name → {type,value}) substituted server-side; built by `buildVariablesPayload`. */
variables?: Querybuildertypesv5QueryRangeRequestDTOVariables;
}
/**
* Builds the V5 query-range request DTO directly from the panel's perses queries (no V1 `Query`
* intermediary). Variables are absent (`variables: {}`) until V2 grows its own variable plumbing.
* intermediary). `variables` carries the runtime selection (empty when the dashboard has none).
*/
export function buildQueryRangeRequest({
queries,
@@ -215,6 +218,7 @@ export function buildQueryRangeRequest({
endMs,
fillGaps = false,
pagination,
variables = {},
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (panelType === PANEL_TYPES.BAR) {
@@ -234,7 +238,7 @@ export function buildQueryRangeRequest({
formatTableResultForUI: panelType === PANEL_TYPES.TABLE,
fillGaps,
},
variables: {},
variables,
};
}

View File

@@ -0,0 +1,105 @@
import type {
Querybuildertypesv5QueryRangeRequestDTOVariables,
Querybuildertypesv5VariableItemDTOValue,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5VariableTypeDTO } from 'api/generated/services/sigNoz.schemas';
import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
VariableSelectionMap,
} from '../VariablesBar/selectionTypes';
/**
* Backend sentinel for "every value selected" on a multi-select dynamic variable.
* V1 parity (`getDashboardVariables`): only dynamic vars collapse to `__all__`;
* query/custom multi-selects send the full value array instead. Lowercase — the
* URL/store `__ALL__` sentinel is a separate serialization concern.
*/
const ALL_VALUES_SENTINEL = '__all__';
/** UI variable grouping → the V5 wire `variables[].type`. */
const VARIABLE_TYPE_TO_DTO: Record<
VariableType,
Querybuildertypesv5VariableTypeDTO
> = {
QUERY: Querybuildertypesv5VariableTypeDTO.query,
CUSTOM: Querybuildertypesv5VariableTypeDTO.custom,
TEXT: Querybuildertypesv5VariableTypeDTO.text,
DYNAMIC: Querybuildertypesv5VariableTypeDTO.dynamic,
};
/** The variable's configured default, used when nothing is selected yet. */
function configuredDefault(
definition: VariableFormModel,
): SelectedVariableValue | undefined {
if (definition.type === 'TEXT') {
return definition.textValue || undefined;
}
return (
definition.defaultValue as { value?: SelectedVariableValue } | undefined
)?.value;
}
/**
* Resolves the wire value for one variable: the dynamic "ALL" sentinel, else the
* user's selection, else the configured default. Returns `undefined` when there
* is nothing meaningful to send (the variable is then omitted from the payload).
*/
function resolveValue(
definition: VariableFormModel,
selection: VariableSelection | undefined,
): Querybuildertypesv5VariableItemDTOValue | undefined {
if (
definition.type === 'DYNAMIC' &&
definition.multiSelect &&
selection?.allSelected
) {
return ALL_VALUES_SENTINEL;
}
const selected = selection?.value;
const hasSelection =
selected !== null &&
selected !== undefined &&
!(typeof selected === 'string' && selected === '');
if (hasSelection) {
return selected as Querybuildertypesv5VariableItemDTOValue;
}
const fallback = configuredDefault(definition);
return fallback == null
? undefined
: (fallback as Querybuildertypesv5VariableItemDTOValue);
}
/**
* Builds the V5 `variables` map from the dashboard's variable definitions and the
* runtime selection, so a panel query substitutes the values the user picked in
* the variable bar (V1 parity with `getDashboardVariables` + the V5 prep). The
* definition list supplies the wire `type` (the selection map carries only values).
*/
export function buildVariablesPayload(
definitions: VariableFormModel[],
selection: VariableSelectionMap,
): Querybuildertypesv5QueryRangeRequestDTOVariables {
const payload: Querybuildertypesv5QueryRangeRequestDTOVariables = {};
definitions.forEach((definition) => {
if (!definition.name) {
return;
}
const value = resolveValue(definition, selection[definition.name]);
if (value === undefined) {
return;
}
payload[definition.name] = {
type: VARIABLE_TYPE_TO_DTO[definition.type],
value,
};
});
return payload;
}

View File

@@ -1,3 +1,4 @@
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
import type { StateCreator } from 'zustand';
import type {
@@ -12,9 +13,19 @@ import type { DashboardStore } from '../useDashboardStore';
* localStorage (mirrored to the URL by the bar for shareable links); it is
* deliberately NOT part of the dashboard spec, so selecting a value never
* patches the dashboard.
*
* `resolvedVariables` is the same selection resolved into the V5 query payload
* shape (`{ name: { type, value } }`), published by `useResolvedVariables` so
* `usePanelQuery` reads it without threading the dashboard spec down the tree
* (the edit-context publish pattern). Transient — not persisted (it is derived
* from `variableValues` + the spec on every load).
*/
export interface VariableSelectionSlice {
variableValues: Record<string, VariableSelectionMap>;
resolvedVariables: Record<
string,
Querybuildertypesv5QueryRangeRequestDTOVariables
>;
setVariableValue: (
dashboardId: string,
name: string,
@@ -22,6 +33,11 @@ export interface VariableSelectionSlice {
) => void;
/** Bulk set (used to seed from URL/localStorage/defaults on load). */
setVariableValues: (dashboardId: string, values: VariableSelectionMap) => void;
/** Publish the resolved V5 variables payload for a dashboard. */
setResolvedVariables: (
dashboardId: string,
variables: Querybuildertypesv5QueryRangeRequestDTOVariables,
) => void;
}
export const createVariableSelectionSlice: StateCreator<
@@ -31,6 +47,7 @@ export const createVariableSelectionSlice: StateCreator<
VariableSelectionSlice
> = (set, get) => ({
variableValues: {},
resolvedVariables: {},
setVariableValue: (dashboardId, name, selection): void => {
const { variableValues } = get();
set({
@@ -46,6 +63,12 @@ export const createVariableSelectionSlice: StateCreator<
variableValues: { ...variableValues, [dashboardId]: values },
});
},
setResolvedVariables: (dashboardId, variables): void => {
const { resolvedVariables } = get();
set({
resolvedVariables: { ...resolvedVariables, [dashboardId]: variables },
});
},
});
/**
@@ -60,3 +83,13 @@ export const selectVariableValues =
(dashboardId: string) =>
(state: DashboardStore): VariableSelectionMap =>
state.variableValues[dashboardId] ?? EMPTY_SELECTION_MAP;
/** Stable empty payload — same rationale as {@link EMPTY_SELECTION_MAP}. */
const EMPTY_RESOLVED_VARIABLES: Querybuildertypesv5QueryRangeRequestDTOVariables =
{};
/** Selector: the resolved V5 variables payload for a dashboard (empty if none). */
export const selectResolvedVariables =
(dashboardId: string) =>
(state: DashboardStore): Querybuildertypesv5QueryRangeRequestDTOVariables =>
state.resolvedVariables[dashboardId] ?? EMPTY_RESOLVED_VARIABLES;

View File

@@ -20,7 +20,8 @@ export type ComponentTypes =
| 'add_panel'
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard';
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@@ -42,6 +43,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
manage_llm_pricing: ['ADMIN'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {

View File

@@ -23,10 +23,9 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/ts/tsconfig.tsbuildinfo",
"paths": {
"*": [
"./src/*"
],
"@constants/*": [
"./src/container/OnboardingContainer/constants/*"
],
@@ -35,7 +34,32 @@
],
"test-mocks/*": [
"./__mocks__/*"
]
],
"api": ["./src/api"],
"AppRoutes": ["./src/AppRoutes"],
"ReactI18": ["./src/ReactI18"],
"store": ["./src/store"],
"styles.scss": ["./src/styles.scss"],
"api/*": ["./src/api/*"],
"AppRoutes/*": ["./src/AppRoutes/*"],
"assets/*": ["./src/assets/*"],
"components/*": ["./src/components/*"],
"constants/*": ["./src/constants/*"],
"container/*": ["./src/container/*"],
"hooks/*": ["./src/hooks/*"],
"lib/*": ["./src/lib/*"],
"mocks-server/*": ["./src/mocks-server/*"],
"modules/*": ["./src/modules/*"],
"pages/*": ["./src/pages/*"],
"parser/*": ["./src/parser/*"],
"periscope/*": ["./src/periscope/*"],
"providers/*": ["./src/providers/*"],
"schemas/*": ["./src/schemas/*"],
"store/*": ["./src/store/*"],
"__tests__/*": ["./src/__tests__/*"],
"tests/*": ["./src/tests/*"],
"types/*": ["./src/types/*"],
"utils/*": ["./src/utils/*"]
},
"plugins": [
{
@@ -52,18 +76,11 @@
],
"include": [
"./src",
"./src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"babel.config.cjs",
"./jest.config.ts",
"./__mocks__",
"./conf/default.conf",
"./public",
"./commitlint.config.ts",
"./vite.config.ts",
"./babel.config.cjs",
"./jest.config.ts",
"./jest.setup.ts",
"./tests/**.ts",
"./**/*.d.ts"
"./vite.config.ts",
"./commitlint.config.ts"
]
}

View File

@@ -145,86 +145,5 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.GetObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetObjects",
Tags: []string{"role"},
Summary: "Get objects for a role by relation",
Description: "Gets all objects connected to the specified role via a given relation type",
Request: nil,
RequestContentType: "",
Response: make([]*coretypes.ObjectGroup, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.Patch, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchRole",
Tags: []string{"role"},
Summary: "Patch role",
Description: "This endpoint patches a role",
Request: new(authtypes.PatchableRole),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/roles/{id}/relations/{relation}/objects", handler.New(
provider.authzMiddleware.CheckResources(provider.authzHandler.PatchObjects, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "PatchObjects",
Tags: []string{"role"},
Summary: "Patch objects for a role by relation",
Description: "Patches the objects connected to the specified role via a given relation type",
Request: new(coretypes.PatchableObjects),
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound, http.StatusBadRequest, http.StatusNotImplemented, http.StatusUnavailableForLegalReasons},
Deprecated: true,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -39,15 +39,6 @@ type AuthZ interface {
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
// Gets the objects associated with the given role and relation.
GetObjects(context.Context, valuer.UUID, valuer.UUID, authtypes.Relation) ([]*coretypes.Object, error)
// Patches the role.
Patch(context.Context, valuer.UUID, *authtypes.Role) error
// Patches the objects in authorization server associated with the given role and relation
PatchObjects(context.Context, valuer.UUID, string, authtypes.Relation, []*coretypes.Object, []*coretypes.Object) error
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
@@ -102,14 +93,8 @@ type Handler interface {
Get(http.ResponseWriter, *http.Request)
GetObjects(http.ResponseWriter, *http.Request)
List(http.ResponseWriter, *http.Request)
Patch(http.ResponseWriter, *http.Request)
PatchObjects(http.ResponseWriter, *http.Request)
Update(http.ResponseWriter, *http.Request)
Check(http.ResponseWriter, *http.Request)

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