Compare commits

...

22 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b1b13af8c0 fix(dashboard-v2): read variable selection from the persisted store, not the editor URL
The dashboard ↔ panel-editor navigation no longer round-trips the selection through the URL (a lagging snapshot could clobber a just-changed value); the editor reads it from the persisted store (localStorage). Drops the now-unused withVariablesSearch.
2026-07-15 18:52:10 +05:30
Ashwin Bhatkal
78b88b4d2b feat(dashboard-v2): rename/delete variable impact dialog
Renaming or deleting a referenced variable is blocked behind a dialog listing every usage (QB / PromQL / ClickHouse panels and other variable defs), with a current → resulting preview, per-usage selection, and inline edits; applied as one JSON-Patch.
2026-07-15 18:52:09 +05:30
Ashwin Bhatkal
7ba0b72359 feat(dashboard-v2): hold panels in loading until referenced variables resolve
A panel waits until every QUERY/DYNAMIC variable it references is ready — a concrete pick and a DYNAMIC ALL are ready immediately, an unselected value or a QUERY ALL waits until it resolves. The wait folds into the panel's loading flags (a disabled query reports neither), so it never flashes "no data". Only referenced variables re-key the cache.
2026-07-15 18:52:09 +05:30
Ashwin Bhatkal
4ca52c3e1d refactor(dashboard-v2): consolidate variable selectors; add bar tooltip + loading bar
Fold the per-type Query/Dynamic/Custom selectors into one VariableValueControl backed by useVariableOptions. Add a hover tooltip showing a variable's dependencies (Depends on / Used by) and a loading bar flush along the control's bottom edge; refine name/dependency colors to design-system tokens.
2026-07-15 18:51:50 +05:30
Ashwin Bhatkal
5375028bcc feat(dashboard-v2): dependency-ordered, value-gated variable fetch engine
Query roots + dynamics fetch immediately; query children unblock only once their parents hold a committed value, so a chain fetches each variable once. The cycle is idempotent per (dashboard·order·time) and resets on page unmount, so a re-mount or editor return can't double it or inherit stale state.
2026-07-15 18:51:50 +05:30
Ashwin Bhatkal
b9bf7be32a feat(dashboard-v2): single default resolver for variable selection
One source of truth for a variable's value (seed → reconcile → payload), shared by the bar, the fetch gate and the query payload so they can't disagree. Precedence stays URL → store → default; ALL materializes once, without an extra round-trip.
2026-07-15 18:51:50 +05:30
Naman Verma
707f01bd9c chore: support non-dsl search in dashboards v2 list apis (#12051)
* chore: support non-dsl search in dashboards v2 list apis

* chore: move free text compilation to visitor

* chore: shorten comments

* fix: support both plain text and dsl together

* fix: ensure empty description dashboards are not excluded in NOT checks
2026-07-14 13:02:58 +00:00
Vinicius Lourenço
3cd0c21703 feat(daemonsets): add ready/misscheduled columns (#12102) 2026-07-14 12:33:21 +00:00
Abhi kumar
181feb3eb9 fix(dashboard-v2): only refetch on-screen panels on time change/auto-refresh (#12113)
Panel visibility was latched (isObserverOnce), so once every panel had
scrolled into view they all stayed query-enabled. A time change or
auto-refresh re-keys every panel's query, refetching all of them at once.

Track the live viewport instead so off-screen panels stay query-disabled
and skip the refetch; they fetch lazily when scrolled back in. Add
staleTime: Infinity so re-entering an unchanged window serves cache rather
than thrash-refetching (key changes and manual refetch still run).
2026-07-14 12:23:04 +00:00
Naman Verma
bf35d12398 chore: add functional unique index for tags (#12029)
* feat: add functional unique index

* chore: add tag migration

* chore: add failing scratch test (to discuss)

* chore: fetch expressions as well in getindices call

* fix: remove tag unique index (will be added in a separate PR)

* fix: remove tag unique index (will be added in a separate PR)

* chore: remove temporary tests

* chore: add functional unique index for tags

* fix: go lint fix

* fix: go lint fix

* chore: better comment for unique index

* test: add test for case insensitive expression equality

* test: add equality test for columns with quotes and capital letters

* test: add test for quoted columns

* chore: add separate type for unique indices with expressions

* fix: update migration file to use correct index

* test: add test for postgres provider

* test: add test for postgres provider

* fix: remove round trip tests
2026-07-14 11:08:59 +00:00
Abhi kumar
1a1f7b2d78 fix(dashboard-v2): bug bash fixes IV (#12056)
* fix(context-menu): keep overlay clickable when opened inside a modal

Portal the popover to body and set pointer-events:auto so a modal's dialog/backdrop can't trap it.

* fix(dashboard-v2): center scrolled-to panels and sections

Default the scroll block to 'center' so a targeted panel lands mid-viewport.

* fix(dashboard-v2): make the view modal refresh button secondary

Use outlined/secondary so refresh doesn't compete with the primary action.

* fix(dashboard-v2): fetch only on-screen panels on dashboard load

RGL unfolds panels from the top-left origin on mount, briefly piling them into the viewport.

The latched observer then marked all visible; suppress the item transition for the first frame.

* chore: readded entry animation
2026-07-14 10:43:14 +00:00
dependabot[bot]
a2778aa45f chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 (#11965)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 10:01:13 +00:00
Srikanth Chekuri
16c1fce326 fix(ruletypes): address rule read round-trip gaps (#12110)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-14 08:36:30 +00:00
Gaurav Tewari
06b762b7ef fix(llm o11y): update unpriced models tab test case (#12108)
* fix: update unpriced models tab test case

* chore: update ci timming

* chore: update comment

* chore: remove comment

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-14 08:22:03 +00:00
Aditya Singh
466edf1f1c feat: timeseries export in logs, traces and metrics explorers (2) (#12055)
* feat(data-export): surface raw V5 response and legend map from GetMetricQueryRange

Keeps the pre-conversion V5 queryRange response and the per-query legend map on the returned MetricQueryRangeSuccessResponse so explorer views can feed client-side export without a refetch. Additive optional fields; the legacy V3 render path is untouched. One chokepoint covers Logs, Traces and Metrics.

* fix(data-export): omit display-only format ids from export headers

'short'/'none' are Grafana-style display formats, not physical units — meaningful on a chart axis but misleading as 'value (short)' in a CSV header. Real units (ms, bytes, ...) are unchanged.

* feat(data-export): timeseries export menu and opt-in TimeSeriesView header

New TimeseriesExportMenu (csv/jsonl, LONG shape default; same look as DownloadOptionsMenu, which stays raw/list-only) rendered from a new opt-in TimeSeriesView header row: unit selector left (onYAxisUnitChange — rendering only, unit state stays with each view), download right (allowExport, needs the surfaced raw V5). Both default off, so views opt in explicitly. Includes gating + menu tests.

* feat(explorer): enable timeseries export in Logs, Traces and Metrics

Logs and Traces pass allowExport + onYAxisUnitChange and drop their external unit-filter headers (one header row: unit left, download right). Metrics passes allowExport only, per chart — its YAxisUnitSelector stays screen-level (persists to the metric, spans N charts). convertDataValueToMs now converts the raw V5 tree too, so Traces duration exports carry the ms values the chart shows instead of raw nanoseconds.

* refactor(data-export): use @signozhq/ui components in the export popover

Review feedback: swap antd Button/Popover/Tooltip for the design-system equivalents (radix-style Popover with controlled open, TooltipSimple, Button with native loading). Styles restructured for the DS DOM with periscope font tokens; test renders via tests/test-utils for the TooltipProvider.
2026-07-14 06:54:51 +00:00
Nikhil Mantri
849353b5c0 feat(infra-monitoring): counts query for counting related entities (#12000)
* chore: added types and open api spec changes

* chore: added method to calculate reason

* chore: per group pod status counts with req metric checks method added

* chore: wired up pod status counts

* chore: pod restarts type added

* chore: added restart counts for the group

* chore: bug in query fix

* chore: onboarding API changes

* chore: integration tests added

* chore: added podcountsbyphase in other entities

* chore: added pod status counts for other entities

* chore: added integration tests for other entities

* chore: added checks api changes for other entities

* chore: rearrangement

* chore: removed succeeded status and mark it as completed

* chore: query beautified

* chore: corrected metrics list for metadata lookup

* chore: removed dead constants

* chore: goroutines for ListHosts

* chore: goroutines for ListPods

* chore: goroutines for ListNodes

* chore: goroutines for ListNamespaces

* chore: goroutines for ListClusters

* chore: goroutines for ListDeployments

* chore: goroutines for ListStatefulsets

* chore: added goroutines for ListStatefulsets, ListJobs and ListDaemonsets

* chore: added function

* chore: added struct changes

* chore: added count attr keys

* chore: wired counts to the response fields

* chore: regenerated API spec

* chore: merged main, resolved conflicts

* chore: nodes count surfacing

* chore: integration tests added

* fix: use tuple mapping to uniquely identify attrs:

* chore: integration tests update
2026-07-14 06:38:27 +00:00
Ashwin Bhatkal
8e4d7c68b3 feat(dashboard-v2): query-first DSL filter for the dashboards list (#12046)
* feat(dashboard-v2): stage-aware DSL autocomplete engine for the list filter

* feat(dashboard-v2): single query-string list-filter state with reflect/splice

* feat(dashboard-v2): drive saved/built-in views off the query string

* feat(dashboard-v2): CodeMirror query box with DSL autocomplete popup

* feat(dashboard-v2): antd Created-by/Updated filter selects, drop the Tags dropdown

* feat(dashboard-v2): wire the DSL filter into the dashboards list (sticky header, pagination)

* fix: small css elevation

* refactor(dashboard-v2): reuse shared operator constants in the DSL grammar

Build STRING_OPS / OPERATOR_MATRIX / VALUELESS_OPERATORS from the shared OPERATORS + negateOperator (constants/antlrQueryConstants) instead of hardcoded strings; drop a stale lint-disable in FilterChips. Addresses review comments.

* fix(dashboard-v2): wrap IN/NOT IN autocomplete values in a bracketed list

Picking a value for an IN/NOT IN clause in the DSL search box inserted a
bare literal ('x') instead of a list (['x']). The value stage now wraps a
fresh pick in a closed bracketed list, appends subsequent picks inside the
existing brackets (deduping already-entered values), and keeps the caret
inside the list so multi-select continues.

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-07-14 05:29:21 +00:00
Gaurav Tewari
2704317c6a feat(llm-pricing): add Unpriced Models tab with billing-model mapping [6/6] (#11813)
* 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

* feat: llm pricing unpriced model

* 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

* feat: add search to search model name

* 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

* feat: move ui to easily accessable tabs

* feat: update test cases

* chore: update text

* chore: self review changes

* chore: self review refactor

* chore: self review changes

* feat(llm-pricing): batch-map unpriced models via top Save + confirm drawer

Replace the per-row "Map to model" button + inline confirm with a single
top-level Save button that opens a confirm drawer listing every pending
mapping in a table (model -> billing model + pricing).

- Batch the save: group selections by target rule so multiple models mapped
  to the same rule append their patterns in one PUT instead of clobbering
- Capped, skeleton-loaded unpriced table (max-height) to avoid layout shift,
  matching the model-costs table
- Restore the dropped .modelCellName style; drop dead inline-confirm styles
- Tests: batch/clobber hook test, tab integration flow, and fix the stale
  'unpriced tab disabled' assertion (the tab is now enabled)

* chore: remove worktree

* chore: revert env.ts

* chore: update llm pricing

* chore: update ui

* chore: sync unpriced modal

* chore(llm-pricing): address Copilot review on unpriced confirm table

- formatSpanCount: use Intl.NumberFormat compact so 999,999 renders as
  "1M" (not "1000.0K") and trailing ".0" is dropped
- confirm drawer extra-buckets container: non-wrapping inline-flex so
  buckets stay on one line at the fixed row height
- soften the misleading "renders identically" comment (extra buckets are
  plain inline text here, not the model-costs Badge chips)

* chore: flex wrap

* chore: self review changes

* feat: add support to remove model

* chore: update ux

* chore: update test cases

* chore: remove unused test cases

* chore: update tests

* chore: remove extra integration

* chore: refactor

* refactor: imports

* refactor: un priced modal

* chore: update unpriced modal

* chore: update map billing mode select

* refactor: code

* refactor: remove comments

* chore: make bg great again

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-14 04:43:28 +00:00
Vinicius Lourenço
9476d8f70f feat(infrastructure-monitoring-v2): add button to open chart at metrics explorer (#12107)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-13 19:29:46 +00:00
Vinicius Lourenço
69a240fdb2 chore(frontend): bump deps (#12052)
* chore(frontend): bump deps

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore(pnpm-lock): missing update

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-13 17:14:22 +00:00
Vinicius Lourenço
6975c4d90c chore(codeowners): update infra monitoring file owner (#12073)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-13 11:47:09 +00:00
Vikrant Gupta
9bfa6e6d59 feat(authz): store role transaction groups as JSON document (#12028)
* feat(authz): store role transaction groups as document of record

Persist a role's transaction groups as JSON on the role row so the role
details page is reconstructed deterministically from SQL instead of being
rebuilt from OpenFGA tuples (which will soon carry opaque hashed telemetry
selectors):

- authtypes: TransactionGroups gains Value/Scan (validated via
  NewTransactionGroups) and MarshalJSON (nil renders as []); NewRole takes
  transactionGroups; NewManagedRoles fills managed docs from the registry;
  RoleWithTransactionGroups removed - Role carries the wire field and the
  AuthZ interface, handler, and OpenAPI responses use *Role; GettableRole
  (without transactionGroups) is the list response
- sqlmigration 099: add role.transaction_groups, backfill custom roles from
  their permission tuples (dual dialect) and managed roles from the registry
- sqlmigration 059: pin insert columns so the live Role model addition does
  not break fresh installs (059 runs before 099)
- ee provider: writes persist the doc alongside FGA tuples (FGA first, SQL
  second, as before); GetWithTransactionGroups reads the doc; the per-type
  ReadTuples fan-out (readAllTuplesForRole) is removed
- audit middleware: log and skip resolved resources carrying a resolution
  error
- frontend: regenerated OpenAPI spec and API types; role list consumers
  retyped to GettableRole; role GET keeps transactionGroups

* fix(authz): reconcile role tuples from openfga state, decouple migration 059

- Update and Delete derive their diff/deletion base from the tuples openfga
  actually holds for the role (readAllTuplesForRole) instead of the stored
  JSON record, so every mutation sweeps drift and residue; the record stays
  a display-only artifact written after the tuple write
- ReadTuples restored on the AuthZ interface with plain passthroughs in both
  providers and the ee server
- TransactionGroups.Value marshals unconditionally (nil renders as [] via
  MarshalJSON) instead of returning a nil driver.Value
- migration 059 uses a migration-local role struct and constructor so live
  Role model changes cannot alter its insert; migration 099 drops the manual
  column-exists guard (AddColumn emits IF NOT EXISTS)

* refactor(authz): split role into domain Role and StorableRole

Replace the Scan/Value/MarshalJSON codecs on TransactionGroups with the
storable pattern: StorableRole is the bun model carrying transaction groups
as raw JSON text, Role is the pure domain/wire type, and
NewStorableRoleFromRole/NewRoleFromStorableRole convert at the store
boundary (nil groups persist as [], reads parse through the validating
constructor). RoleStore and sqlauthzstore speak StorableRole; both
providers convert; handlers and the wire contract are unchanged.

* revert(authz): restore TransactionGroups codecs over the storable split

Role is a bun relation target (UserRole.Role, ServiceAccountRole.Role), so
splitting it into StorableRole/Role cascaded: relations must point at the
bun model, which broke the user-roles join and leaked the storable shape
into user and service account responses. Keep the single Role model with
Scan/Value/MarshalJSON on TransactionGroups; the storable split fits leaf
models only.

This reverts commit 73aa7d32b1 and keeps transaction_test.go deleted.

* refactor(authz): use bun models in migration 099, wire oss role get

- migration 099 follows the migration-local row struct pattern: bun
  NewSelect/NewUpdate for the role table reads and backfill writes; the
  openfga store and tuple lookups stay raw like 081/083
- oss provider Get reads the role from the store instead of returning
  unsupported

* refactor(authz): org-scoped backfill in migration 099, empty groups on null scan

- migration 099 iterates organizations: per org it backfills custom roles
  from their permission tuples (readRoleTuples helper) and managed roles
  from the registry (JSON precomputed per role name)
- TransactionGroups scans SQL NULL as an empty slice so the api always
  renders transactionGroups as []; nullzero keeps writing NULL for nil

* fix(authz): pass unique constraints to add column in migration 099
2026-07-13 11:42:42 +00:00
175 changed files with 8570 additions and 3042 deletions

2
.github/CODEOWNERS vendored
View File

@@ -189,7 +189,9 @@ go.mod @therealpandey
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHostsV2/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8sV2/ @SigNoz/pulse-frontend
## Alerts
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend

View File

@@ -543,6 +543,31 @@ components:
required:
- id
type: object
AuthtypesGettableRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesGettableToken:
properties:
accessToken:
@@ -694,43 +719,6 @@ components:
- detach
type: string
AuthtypesRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
@@ -758,6 +746,18 @@ components:
- orgId
- transactionGroups
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -4259,6 +4259,34 @@ components:
type: number
clusterName:
type: string
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
namespaces:
format: int64
type: integer
nodes:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- nodes
- namespaces
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4279,6 +4307,7 @@ components:
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesClusters:
@@ -4800,6 +4829,26 @@ components:
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
properties:
daemonSets:
format: int64
type: integer
deployments:
format: int64
type: integer
jobs:
format: int64
type: integer
statefulSets:
format: int64
type: integer
required:
- deployments
- daemonSets
- jobs
- statefulSets
type: object
meta:
additionalProperties:
type: string
@@ -4823,6 +4872,7 @@ components:
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- counts
- meta
type: object
InframonitoringtypesNamespaces:
@@ -12022,7 +12072,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesGettableRole'
type: array
status:
type: string
@@ -12206,7 +12256,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
$ref: '#/components/schemas/AuthtypesRole'
status:
type: string
required:

View File

@@ -119,10 +119,6 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
return results, nil
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.openfgaServer.Write(ctx, additions, deletions)
}
@@ -131,10 +127,6 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
return provider.pkgAuthzService.Get(ctx, orgID, id)
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -183,7 +175,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Create(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())
@@ -208,70 +200,31 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
return err
}
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
return provider.store.Create(ctx, role)
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, 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())
}
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return existingRole, nil
}
err = provider.store.Create(ctx, role)
if err != nil {
return nil, err
}
return role, nil
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, 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())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *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())
}
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
if err != nil {
return err
}
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
@@ -287,7 +240,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
return provider.store.Update(ctx, orgID, updatedRole.Role)
return provider.store.Update(ctx, orgID, updatedRole)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
@@ -296,7 +249,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
role, err := provider.Get(ctx, orgID, id)
if err != nil {
return err
}
@@ -312,7 +265,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return err
}
@@ -324,6 +277,24 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
tuples := []*openfgav1.TupleKey{}
@@ -375,21 +346,3 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}

View File

@@ -105,10 +105,6 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return server.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -1,29 +0,0 @@
import { PropsWithChildren } from 'react';
type CommonProps = PropsWithChildren<{
className?: string;
minSize?: number;
maxSize?: number;
defaultSize?: number;
direction?: 'horizontal' | 'vertical';
autoSaveId?: string;
withHandle?: boolean;
}>;
export function ResizablePanelGroup({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizablePanel({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizableHandle({ className }: CommonProps): JSX.Element {
return <div className={className} />;
}

View File

@@ -21,7 +21,6 @@ const config: Config.InitialOptions = {
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^motion/react$': '<rootDir>/__mocks__/motionMock.tsx',
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,

View File

@@ -49,7 +49,6 @@
"@sentry/vite-plugin": "5.3.0",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.23",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
@@ -128,7 +127,7 @@
"timestamp-nano": "^1.0.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
"uuid": "^8.3.2",
"uuid": "14.0.1",
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-html": "3.2.2",
"zod": "4.3.6",
@@ -222,34 +221,5 @@
"*.(scss|css)": [
"stylelint"
]
},
"resolutions": {
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"debug": "4.3.4",
"semver": "7.5.4",
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "4.1.1",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.6",
"brace-expansion": "^2.0.3",
"on-headers": "^1.1.0",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"vite": "npm:rolldown-vite@7.3.1",
"dompurify": "3.4.11",
"js-yaml@3": "3.15.0",
"js-yaml@4": "4.2.0",
"yaml@1": "1.10.3",
"react-router@6": "6.30.4",
"markdown-it": "14.2.0",
"mdast-util-to-hast@13": "13.2.1",
"protocol-buffers-schema": "3.6.1"
}
}

1864
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,53 @@
trustPolicy: no-downgrade
blockExoticSubdeps: true
minimumReleaseAge: 2880 # 2d
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- '@signozhq/*'
blockExoticSubdeps: true
minimumReleaseAgeStrict: true
# Security floors for vulnerable transitive deps. Where possible, targets are
# capped to avoid crossing breaking versions (major; and minor for 0.x).
# Some entries may still force a breaking bump when no safe release exists
# within the consumers constraint—see per-entry notes.
overrides:
# via: direct devDep @babel/core ^7.22.11 (+ babel plugin peers)
# remove: bump @babel/core in package.json to ^7.29.6
'@babel/core@<=7.29.0': '>=7.29.6 <8'
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
cookie@<0.7.0: '>=0.7.1 <1'
# via: direct dep dompurify 3.4.0; @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
# remove: bump direct dep to 3.4.11; @grafana/data (latest 13.1.0) and
# monaco-editor (latest 0.55.1) still pin vulnerable versions — blocked
dompurify@<=3.4.10: '>=3.4.11 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); orval@8.9.1 (^0.27.4); ts-jest@29.4.9 (~0.27.4)
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
js-cookie@<=3.0.5: '>=3.0.7 <4'
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'
# via: direct dep react-router-dom-v5-compat@6.30.3 (react-router 6.30.3 exact)
# remove: bump react-router-dom-v5-compat to 6.30.4. Do NOT open the cap:
# react-router >=7 requires React 19 and breaks the app-wide CompatRouter
react-router@>=6.7.0 <6.30.4: '>=6.30.4 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'
# via: jest > babel-plugin-macros > cosmiconfig@7 (yaml ^1.10.0);
# typescript-plugin-css-modules > postcss-load-config@3 (^1.10.2)
# remove: blocked — babel-plugin-macros 3.1.0 (latest) still uses cosmiconfig@7
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
trustPolicy: no-downgrade
trustPolicyExclude:
- 'semver@6.3.1 || 5.7.2'

View File

@@ -2082,6 +2082,39 @@ export interface AuthtypesGettableAuthDomainDTO {
updatedAt?: string;
}
export interface AuthtypesGettableRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesGettableTokenDTO {
/**
* @type string
@@ -2325,39 +2358,6 @@ export interface AuthtypesPostableUserRoleDTO {
}
export interface AuthtypesRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
@@ -5669,6 +5669,39 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
namespaces: number;
/**
* @type integer
* @format int64
*/
nodes: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -5813,6 +5846,10 @@ export interface InframonitoringtypesClusterRecordDTO {
* @type string
*/
clusterName: string;
/**
* @type object
*/
counts: InframonitoringtypesClusterRecordDTOCounts;
/**
* @type object,null
*/
@@ -6368,6 +6405,29 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
* @format int64
*/
daemonSets: number;
/**
* @type integer
* @format int64
*/
deployments: number;
/**
* @type integer
* @format int64
*/
jobs: number;
/**
* @type integer
* @format int64
*/
statefulSets: number;
};
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6379,6 +6439,10 @@ export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
* @type object
*/
counts: InframonitoringtypesNamespaceRecordDTOCounts;
/**
* @type object,null
*/
@@ -10614,7 +10678,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesRoleDTO[];
data: AuthtypesGettableRoleDTO[];
/**
* @type string
*/
@@ -10636,7 +10700,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleWithTransactionGroupsDTO;
data: AuthtypesRoleDTO;
/**
* @type string
*/

View File

@@ -12,5 +12,4 @@
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/resizable';
import '@signozhq/ui';

View File

@@ -3,7 +3,7 @@ import { Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRoles } from 'api/generated/services/role';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import APIError from 'types/api/error';
import { popupContainer } from 'utils/selectPopupContainer';
@@ -16,7 +16,7 @@ export interface RoleOption {
}
export function useRoles(): {
roles: AuthtypesRoleDTO[];
roles: AuthtypesGettableRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
@@ -33,7 +33,7 @@ export function useRoles(): {
}
export function getRoleOptions(
roles: AuthtypesRoleDTO[],
roles: AuthtypesGettableRoleDTO[],
valueField: 'id' | 'name',
): RoleOption[] {
return roles.map((role) => ({
@@ -79,7 +79,7 @@ interface BaseProps {
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: AuthtypesRoleDTO[];
roles?: AuthtypesGettableRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;

View File

@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import RolesSelect from 'components/RolesSelect';
@@ -28,7 +28,7 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesRoleDTO[];
availableRoles: AuthtypesGettableRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;

View File

@@ -139,21 +139,31 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
</ColumnHeader>
),
accessorFn: (row): number => row.currentNodes,
width: { min: 180 },
width: { min: 210 },
enableSort: false,
enableResize: true,
cell: ({ row }): React.ReactNode => (
<GroupedStatusCounts
items={[
{
value: row.readyNodes,
label: 'Ready',
color: Color.BG_FOREST_500,
},
{
value: row.currentNodes,
label: 'Current',
color: Color.BG_FOREST_500,
color: Color.BG_ROBIN_500,
},
{
value: row.desiredNodes,
label: 'Desired',
color: Color.BG_ROBIN_500,
color: Color.BG_SAKURA_400,
},
{
value: row.misscheduledNodes,
label: 'Misscheduled',
color: Color.BG_AMBER_500,
},
]}
/>
@@ -312,6 +322,30 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'ready_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#ready">
Ready Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.readyNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const readyNodes = value as number;
return (
<ValidateColumnValueWrapper
value={readyNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="ready node"
>
<TanStackTable.Text>{readyNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'current_nodes',
header: (): React.ReactNode => (
@@ -360,4 +394,28 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
);
},
},
{
id: 'misscheduled_nodes',
header: (): React.ReactNode => (
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#misscheduled">
Misscheduled Nodes
</ColumnHeader>
),
accessorFn: (row): number => row.misscheduledNodes,
width: { min: 140 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const misscheduledNodes = value as number;
return (
<ValidateColumnValueWrapper
value={misscheduledNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="misscheduled node"
>
<TanStackTable.Text>{misscheduledNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -14,11 +14,28 @@
box-sizing: border-box;
}
.entityMetricsTitleContainer {
display: flex;
align-items: center;
gap: 8px;
}
.entityMetricsTitle {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.metricsExplorerLink {
display: flex;
align-items: center;
color: var(--l2-foreground);
transition: opacity 0.2s;
&:hover {
color: var(--l3-foreground);
}
}
.metricsHeader {
display: flex;
justify-content: flex-end;

View File

@@ -1,6 +1,8 @@
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import { Link } from 'react-router-dom';
import { Compass } from '@signozhq/icons';
import { Skeleton, Tooltip } from 'antd';
import cx from 'classnames';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
@@ -19,6 +21,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { buildEntityMetricsChartConfig } from './configBuilder';
@@ -204,9 +207,31 @@ function EntityMetrics<T>({
key={entityWidgetInfo[idx].title}
className={styles.entityMetricsCol}
>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
<div className={styles.entityMetricsTitleContainer}>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
{queryPayloads[idx] &&
queryPayloads[idx].graphType !== PANEL_TYPES.TABLE && (
<Tooltip title="Open in Metrics Explorer">
<Link
to={getMetricsExplorerUrl({
query: queryPayloads[idx].query,
...(selectedInterval && selectedInterval !== 'custom'
? { relativeTime: selectedInterval }
: {
startTimeMs: timeRange.startTime * 1000,
endTimeMs: timeRange.endTime * 1000,
}),
})}
className={styles.metricsExplorerLink}
data-testid={`open-metrics-explorer-${idx}`}
>
<Compass size={14} />
</Link>
</Tooltip>
)}
</div>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}
</div>

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
@@ -295,17 +296,19 @@ const renderEntityMetrics = (overrides = {}): any => {
};
return render(
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>,
<MemoryRouter>
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
</MemoryRouter>,
);
};
@@ -334,8 +337,8 @@ const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
];
const mockQueryPayloads = [
{ graphType: 'graph' }, // time_series
{ graphType: 'table' }, // table
{ graphType: 'graph', query: { queryType: 'builder' } }, // time_series
{ graphType: 'table', query: { queryType: 'builder' } }, // table
];
describe('EntityMetrics', () => {
@@ -442,6 +445,34 @@ describe('EntityMetrics', () => {
);
});
it('renders metrics explorer link only for non-table panels', () => {
renderEntityMetrics();
expect(screen.getByTestId('open-metrics-explorer-0')).toBeInTheDocument();
expect(
screen.queryByTestId('open-metrics-explorer-1'),
).not.toBeInTheDocument();
});
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
renderEntityMetrics({ selectedInterval: '5m' as Time });
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
expect(href).toContain('relativeTime=5m');
expect(href).not.toContain('startTime=');
expect(href).not.toContain('endTime=');
});
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
renderEntityMetrics({ selectedInterval: 'custom' as Time });
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
expect(href).toContain(`startTime=${mockTimeRange.startTime * 1000}`);
expect(href).toContain(`endTime=${mockTimeRange.endTime * 1000}`);
expect(href).not.toContain('relativeTime=');
});
it('passes correct parameters to useEntityMetrics hook', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(

View File

@@ -3,3 +3,13 @@
flex-direction: column;
gap: var(--spacing-8);
}
.tabLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tabBadge {
margin: 0;
}

View File

@@ -1,30 +1,58 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from '@signozhq/ui/tabs';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import ModelCostTabPanel from './ModelCostTabPanel';
import { MODEL_COSTS_TAB, TAB_KEY, UNPRICED_MODELS_TAB } from './constants';
import styles from './LLMObservabilityModelPricing.module.scss';
import ModelCostTabPanel from './ModelCostTabPanel';
import UnpricedModelsTab from './UnpricedModelsTab';
function LLMObservabilityModelPricing(): JSX.Element {
const [activeTab, setActiveTab] = useQueryState(
TAB_KEY,
parseAsStringEnum([MODEL_COSTS_TAB, UNPRICED_MODELS_TAB]).withDefault(
MODEL_COSTS_TAB,
),
);
// Count powers the tab badge; deduped with the tab's own fetch by react-query.
const { data } = useListUnmappedLLMModels();
const unpricedCount = data?.data?.items?.length ?? 0;
return (
<div
className={styles.llmObservabilityModelPricing}
data-testid="llm-observability-model-pricing-page"
>
<Tabs
// Model costs is the only enabled tab for now, so default to it. When
// the unpriced-models tab lands in a later PR.
defaultValue="model-costs"
value={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}
items={[
{
key: 'model-costs',
key: MODEL_COSTS_TAB,
label: 'Model costs',
children: <ModelCostTabPanel />,
},
{
// Unpriced-models tab lands in a later PR.
key: 'unpriced-models',
label: 'Unpriced models',
disabled: true,
children: null,
key: UNPRICED_MODELS_TAB,
label: (
<span className={styles.tabLabel}>
Unpriced models
{unpricedCount > 0 && (
<Badge
variant="default"
className={styles.tabBadge}
data-testid="unpriced-models-count"
>
{unpricedCount}
</Badge>
)}
</span>
),
children: <UnpricedModelsTab />,
},
]}
/>

View File

@@ -3,6 +3,7 @@ import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
@@ -10,9 +11,16 @@ import {
EMPTY_DRAFT,
TOAST_MODEL_COST_ADDED,
TOAST_MODEL_COST_UPDATED,
} from '../../../../constants';
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
import { buildRulePayload, draftFromRule } from '../../../../utils';
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type {
DrawerDraft,
DrawerMode,
PricingRule,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
buildRulePayload,
draftFromRule,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
interface UseModelCostDrawerResult {
isOpen: boolean;
@@ -38,17 +46,26 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
const { mutateAsync: createOrUpdate, isLoading: isSaving } =
useCreateOrUpdateLLMPricingRules();
// Adding pricing can also resolve a model that was showing up as unpriced, so
// refresh the unmapped list (and its tab-badge count) alongside the rules list.
const invalidateList = useCallback(async (): Promise<void> => {
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
]);
}, [queryClient]);
const openForAdd = useCallback((): void => {
// prefillModelName seeds the billing model ID when adding from an unpriced
// model row, so the user only has to fill in pricing.
const openForAdd = useCallback((prefillModelName?: string): void => {
setMode('add');
setInitialDraft({
...EMPTY_DRAFT,
modelName: '',
modelName: prefillModelName ?? '',
patterns: [],
});
setSelectedRuleId(null);

View File

@@ -25,7 +25,6 @@
.drawerSurface {
padding: var(--spacing-7);
border-radius: 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,27 @@
.unpricedModelsTab {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
background: color-mix(in srgb, var(--bg-cherry-400) 8%, transparent);
}

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import styles from './UnpricedModelsTab.module.scss';
import ModelCostDrawer, {
useModelCostDrawer,
} from '../ModelCostTabPanel/components/ModelCostDrawer';
import type { PricingRule, UnpricedModel } from '../types';
import MapConfirmDialog from './components/MapConfirmDialog';
import type { UnpricedColumnsConfig } from './components/UnpricedModelsTable/TableConfig';
import UnpricedModelsTable from './components/UnpricedModelsTable';
import { useUnpricedModelMapping } from './hooks/useUnpricedModelMapping';
import { usePendingMappingStore } from './usePendingMappingStore';
function UnpricedModelsTab(): JSX.Element {
const { data, isLoading, isError } = useListUnmappedLLMModels();
const { user } = useAppContext();
const [canManagePricing] = useComponentPermission(
['manage_llm_pricing'],
user.role,
);
const models: UnpricedModel[] = useMemo(() => data?.data?.items || [], [data]);
// Picking a billing model stages a single mapping for confirmation; the
// mapping only commits once the user confirms in the dialog. Kept in a store
// (not local state) so the row's memoized select trigger can mirror the pick —
// see usePendingMappingStore.
const pendingMapping = usePendingMappingStore((state) => state.pending);
const setPending = usePendingMappingStore((state) => state.setPending);
const clearPending = usePendingMappingStore((state) => state.clearPending);
const { mapModel, isSaving } = useUnpricedModelMapping();
// Reset any staged mapping when leaving the tab so a stale pick doesn't reopen
// the dialog on remount (the store outlives this component).
useEffect(() => (): void => clearPending(), [clearPending]);
// Reuses the "Model costs" add/edit drawer to define brand-new pricing for a
// model that has no matching billing model to map onto. Saving resolves the
// model, so it drops off this tab (the drawer invalidates the unmapped list).
const drawer = useModelCostDrawer();
const onRequestMap = useCallback(
(model: UnpricedModel, rule: PricingRule): void => {
setPending({ model, rule });
},
[setPending],
);
const onConfirmMap = useCallback(async (): Promise<void> => {
if (!pendingMapping) {
return;
}
const didSave = await mapModel(pendingMapping);
if (didSave) {
clearPending();
}
}, [mapModel, pendingMapping, clearPending]);
// openForAdd is stable (useCallback with no deps); drawer itself is a fresh
// object each render, so depend on the method, not the object.
const { openForAdd } = drawer;
const onCreateNew = useCallback(
(modelName: string): void => openForAdd(modelName),
[openForAdd],
);
const columnsConfig = useMemo<UnpricedColumnsConfig>(
() => ({
canManage: canManagePricing,
onRequestMap,
onCreateNew,
}),
[canManagePricing, onRequestMap, onCreateNew],
);
return (
<div className={styles.unpricedModelsTab}>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
{isError && (
<div className={styles.error}>
<Typography.Text as="p" size="small" color="danger" role="alert">
Failed to load unpriced models. Please try again.
</Typography.Text>
</div>
)}
<UnpricedModelsTable
models={models}
isLoading={isLoading}
columnsConfig={columnsConfig}
/>
{pendingMapping && (
<MapConfirmDialog
open
model={pendingMapping.model}
rule={pendingMapping.rule}
isSaving={isSaving}
onConfirm={onConfirmMap}
onCancel={clearPending}
/>
)}
{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}
/>
)}
</div>
);
}
export default UnpricedModelsTab;

View File

@@ -0,0 +1,180 @@
import type { LlmpricingruletypesUpdatableLLMPricingRulesDTO } from 'api/generated/services/sigNoz.schemas';
import {
LLM_PRICING_ENDPOINT,
LLM_UNMAPPED_ENDPOINT,
makeListResponse,
makeUnmappedResponse,
mockRules,
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import UnpricedModelsTab from '../UnpricedModelsTab';
const toastSuccess = jest.fn();
const toastError = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): void => toastSuccess(...args),
error: (...args: unknown[]): void => toastError(...args),
},
}));
const MODEL = 'gpt-4o-mini-2024-07-18';
function setup(): void {
server.use(
rest.get(LLM_UNMAPPED_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeUnmappedResponse())),
),
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeListResponse(mockRules))),
),
);
}
// Picks a billing model in a row's dropdown: open the combobox, then click the
// option. The dropdown fetches its options from the rules list (mocked above).
async function selectRule(
user: ReturnType<typeof userEvent.setup>,
modelName: string,
ruleId: string,
): Promise<void> {
await user.click(screen.getByTestId(`map-to-select-${modelName}`));
await user.click(await screen.findByTestId(`map-to-option-${ruleId}`));
}
describe('UnpricedModelsTab (integration)', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setup();
});
afterEach(() => {
server.resetHandlers();
toastSuccess.mockClear();
toastError.mockClear();
});
it('opens the confirm dialog with the target rule pricing when a model is picked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Picking a rule stages the mapping in the confirm dialog.
await selectRule(user, MODEL, 'rule-openai');
const confirmItem = await screen.findByTestId(
`unpriced-map-confirm-item-${MODEL}`,
);
expect(confirmItem).toBeInTheDocument();
// Shows the target billing model + its pricing so the user can eyeball it.
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
expect(screen.getByText('$3.00')).toBeInTheDocument();
expect(screen.getByText('$9.00')).toBeInTheDocument();
// While the dialog is open, the row's trigger mirrors the staged pick
// instead of the placeholder.
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('openai:gpt-4o ($3.00/$9.00)'),
).toBeInTheDocument();
});
it('reverts the row trigger to the placeholder when the mapping is cancelled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
expect(
within(screen.getByTestId(`map-to-select-${MODEL}`)).getByText(
'openai:gpt-4o ($3.00/$9.00)',
),
).toBeInTheDocument();
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() => {
const trigger = screen.getByTestId(`map-to-select-${MODEL}`);
expect(
within(trigger).getByText('Select / Create a pricing model'),
).toBeInTheDocument();
});
});
it('commits the mapping in one request when confirmed', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-confirm-btn'));
await waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0].rules).toHaveLength(1);
expect(sent[0].rules?.[0].modelPattern).toContain(MODEL);
await waitFor(() =>
expect(toastSuccess).toHaveBeenCalledWith('Mapped model'),
);
// Dialog closes on success.
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
});
it('cancels the mapping without committing', async () => {
const sent: LlmpricingruletypesUpdatableLLMPricingRulesDTO[] = [];
server.use(
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
sent.push(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success' }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
await selectRule(user, MODEL, 'rule-openai');
await user.click(await screen.findByTestId('unpriced-map-cancel-btn'));
await waitFor(() =>
expect(
screen.queryByTestId(`unpriced-map-confirm-item-${MODEL}`),
).not.toBeInTheDocument(),
);
expect(sent).toHaveLength(0);
});
it('opens the add-cost drawer prefilled when creating pricing for a model', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<UnpricedModelsTab />);
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));
// The shared add-cost drawer opens with the model name prefilled.
const drawerTitle = await screen.findByText('Add model cost');
expect(drawerTitle).toBeInTheDocument();
expect(screen.getByTestId('drawer-model-id-input')).toHaveValue(MODEL);
});
});

View File

@@ -0,0 +1,40 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.mapping {
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
.arrow {
flex-shrink: 0;
opacity: 0.6;
}
.pricing {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-6);
border-radius: var(--radius-2);
border: 1px solid var(--l2-border);
background: var(--l3-background);
}
.pricingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
}

View File

@@ -0,0 +1,124 @@
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight, Check, Link2, X } from '@signozhq/icons';
import { startCase } from 'lodash-es';
import styles from './MapConfirmDialog.module.scss';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import {
formatPricePerMillion,
getCanonicalId,
getExtraBuckets,
} from 'container/LLMObservability/Settings/ModelPricing/utils';
interface MapConfirmDialogProps {
open: boolean;
model: UnpricedModel;
rule: PricingRule;
isSaving: boolean;
onConfirm: () => void;
onCancel: () => void;
}
// Per-row confirm step before mapping an unpriced model onto an existing billing
// model. Mapping appends the span's model name as a match pattern on the chosen
// rule, so the model inherits that rule's pricing — shown here so the user can
// eyeball the rates before committing. Dismissible (outside click / close), since
// mapping is reversible (re-map or edit the rule's patterns afterwards).
function MapConfirmDialog({
open,
model,
rule,
isSaving,
onConfirm,
onCancel,
}: MapConfirmDialogProps): JSX.Element {
const extraBuckets = getExtraBuckets(rule);
const pricingRows = [
{ key: 'input', label: 'Input / 1M', value: rule.pricing?.input },
{ key: 'output', label: 'Output / 1M', value: rule.pricing?.output },
...extraBuckets.map((bucket) => ({
key: bucket.key,
label: startCase(bucket.key),
value: bucket.pricePerMillion,
})),
];
const footer = (
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
disabled={isSaving}
prefix={<X size={12} />}
testId="unpriced-map-cancel-btn"
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
onClick={onConfirm}
prefix={<Check size={12} />}
testId="unpriced-map-confirm-btn"
>
Map model
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onCancel();
}
}}
width="base"
title="Map to billing model"
titleIcon={<Link2 size={16} />}
footer={footer}
testId="unpriced-map-confirm-dialog"
>
<div className={styles.body}>
<Typography.Text as="p" size="small" color="muted">
Spans from this model will be priced using the selected billing model.
</Typography.Text>
<div className={styles.mapping}>
<Typography.Text
weight="semibold"
testId={`unpriced-map-confirm-item-${model.modelName}`}
>
{model.modelName}
</Typography.Text>
<ArrowRight size={14} className={styles.arrow} />
<Typography.Text weight="semibold">{getCanonicalId(rule)}</Typography.Text>
</div>
<div className={styles.pricing}>
{pricingRows.map((row) => (
<div className={styles.pricingRow} key={row.key}>
<Typography.Text as="span" size="small" color="muted">
{row.label}
</Typography.Text>
<Typography.Text as="span" size="small" weight="semibold">
{formatPricePerMillion(row.value)}
</Typography.Text>
</div>
))}
</div>
</div>
</DialogWrapper>
);
}
export default MapConfirmDialog;

View File

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

View File

@@ -0,0 +1,27 @@
.mapToCell {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.mapToSelect {
width: 100%;
max-width: 280px;
}
.mapToDropdown {
width: 280px;
}
.skeletonList {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-4);
}
.skeletonRow {
display: block;
width: 100%;
}

View File

@@ -0,0 +1,138 @@
import { useState } from 'react';
import {
Combobox,
ComboboxCommand,
ComboboxContent,
ComboboxCreateItem,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Plus } from '@signozhq/icons';
import { Skeleton } from 'antd';
import styles from './MapToBillingModelSelect.module.scss';
import { RULE_OPTIONS_LIMIT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
import { getRuleOptionLabel } from 'container/LLMObservability/Settings/ModelPricing/utils';
import { usePendingMappingLabel } from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/usePendingMappingStore';
import { useMapToBillingModelSearch } from './useMapToBillingModelSearch';
// One placeholder row per fetched option, so the skeleton height matches the
// loaded list. Stable keys derived from the fetch limit.
const SKELETON_ROW_KEYS = Array.from(
{ length: RULE_OPTIONS_LIMIT },
(_, index) => `skeleton-${index}`,
);
interface MapToBillingModelSelectProps {
modelName: string;
disabled: boolean;
onSelect: (rule: PricingRule) => void;
onCreateNew: () => void;
}
// Searchable, server-paged dropdown for picking the billing model an unpriced
// model maps onto. Only RULE_OPTIONS_LIMIT rules are fetched at a time; typing
// narrows the set via the rules API rather than client-side filtering, so cmdk's
// own filter is disabled (shouldFilter={false}). The dropdown is a pure picker —
// choosing a rule hands it up to the confirm dialog rather than persisting a
// selection here. The trigger only mirrors the staged pick (read from the
// pending-mapping store) while that dialog is open, reverting on confirm/cancel.
function MapToBillingModelSelect({
modelName,
disabled,
onSelect,
onCreateNew,
}: MapToBillingModelSelectProps): JSX.Element {
const [open, setOpen] = useState(false);
const { searchText, setSearchText, rules, rulesById, isFetching } =
useMapToBillingModelSearch(open);
const selectedLabel = usePendingMappingLabel(modelName);
const handleSelect = (ruleId: string): void => {
const rule = rulesById.get(ruleId);
if (rule) {
onSelect(rule);
}
setOpen(false);
};
const handleCreateNew = (): void => {
setOpen(false);
onCreateNew();
};
return (
<div className={styles.mapToCell}>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
className={styles.mapToSelect}
disabled={disabled}
placeholder="Select / Create a pricing model"
value={selectedLabel}
testId={`map-to-select-${modelName}`}
/>
<ComboboxContent className={styles.mapToDropdown}>
<ComboboxCommand shouldFilter={false}>
<ComboboxInput
value={searchText}
onValueChange={setSearchText}
placeholder="Search billing models"
testId={`map-to-search-${modelName}`}
/>
<ComboboxList>
{rules.map((rule) => (
<ComboboxItem
key={rule.id}
value={rule.id}
onSelect={(): void => handleSelect(rule.id)}
data-testid={`map-to-option-${rule.id}`}
>
{getRuleOptionLabel(rule)}
</ComboboxItem>
))}
{isFetching && (
<div
className={styles.skeletonList}
data-testid={`map-to-loading-${modelName}`}
>
{SKELETON_ROW_KEYS.map((key) => (
<Skeleton.Input
key={key}
active
block
size="small"
className={styles.skeletonRow}
/>
))}
</div>
)}
{!isFetching && rules.length === 0 && (
<ComboboxEmpty>No billing models found</ComboboxEmpty>
)}
</ComboboxList>
{/* Kept outside ComboboxList so it stays pinned as a footer while the
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
</ComboboxCommand>
</ComboboxContent>
</Combobox>
</div>
);
}
export default MapToBillingModelSelect;

View File

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

View File

@@ -0,0 +1,44 @@
import { useMemo, useState } from 'react';
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
import useDebounce from 'hooks/useDebounce';
import {
RULE_OPTIONS_LIMIT,
SEARCH_DEBOUNCE_MS,
} from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { PricingRule } from 'container/LLMObservability/Settings/ModelPricing/types';
interface UseMapToBillingModelSearch {
searchText: string;
setSearchText: (value: string) => void;
rules: PricingRule[];
// Fetched rules keyed by id, so a pick can resolve to its full rule object.
rulesById: Map<string, PricingRule>;
isFetching: boolean;
}
// Server-side search for the per-row "Map to billing model" dropdown. Only
// RULE_OPTIONS_LIMIT rules are fetched at a time; typing narrows the set via the
// rules API's `q` param instead of filtering client-side. The fetch is gated on
// `enabled` (the dropdown's open state) so closed rows don't fetch, and
// react-query dedupes identical query keys so rows sharing a term hit the network
// once.
export function useMapToBillingModelSearch(
enabled: boolean,
): UseMapToBillingModelSearch {
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
const { data, isFetching } = useListLLMPricingRules(
{ offset: 0, limit: RULE_OPTIONS_LIMIT, q: debouncedSearch || undefined },
{ query: { enabled } },
);
const rules = useMemo<PricingRule[]>(() => data?.data?.items || [], [data]);
const rulesById = useMemo(
() => new Map(rules.map((rule) => [rule.id, rule])),
[rules],
);
return { searchText, setSearchText, rules, rulesById, isFetching };
}

View File

@@ -0,0 +1,2 @@
export { getUnpricedModelsColumns } from './table.config';
export type { UnpricedColumnsConfig } from './table.config';

View File

@@ -0,0 +1,88 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { TableColumnDef } from 'components/TanStackTableView';
import MapToBillingModelSelect from 'container/LLMObservability/Settings/ModelPricing/UnpricedModelsTab/components/MapToBillingModelSelect';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { formatSpanCount } from 'container/LLMObservability/Settings/ModelPricing/utils';
import styles from './tableConfig.module.scss';
export interface UnpricedColumnsConfig {
canManage: boolean;
// Picking a billing model requests a mapping — the tab opens a confirm dialog
// and commits that single mapping there (no batch/selection state here).
onRequestMap: (model: UnpricedModel, rule: PricingRule) => void;
// Opens the add-cost drawer prefilled with the row's model name.
onCreateNew: (modelName: string) => void;
}
// Column definitions for the unpriced-models TanStackTable. Sorting is off — the
// unmapped-models endpoint returns the full set in one shot with no ordering knob.
// Each row's action is self-contained: pick a billing model to map onto (confirmed
// in a dialog) or create pricing for the model, so there's no bulk-save column.
export function getUnpricedModelsColumns({
canManage,
onRequestMap,
onCreateNew,
}: UnpricedColumnsConfig): TableColumnDef<UnpricedModel>[] {
return [
{
id: 'model',
header: 'Model (from spans)',
accessorFn: (row): string => row.modelName,
width: { min: 240, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<Typography.Text
weight="semibold"
truncate={1}
testId={`unpriced-model-name-${row.modelName}`}
>
{row.modelName}
</Typography.Text>
),
},
{
id: 'provider',
header: 'Provider',
width: { min: 140 },
enableMove: false,
cell: ({ row }): string => row.provider || 'Unknown',
},
{
id: 'spans',
header: 'Spans',
width: { min: 100 },
enableMove: false,
cell: ({ row }): JSX.Element => (
<Badge
color="cherry"
variant="outline"
className={styles.spansBadge}
data-testid={`unpriced-spans-${row.modelName}`}
>
{formatSpanCount(row.spanCount)}
</Badge>
),
},
{
id: 'mapTo',
header: 'Map to billing model',
width: { min: 280, default: '100%' },
enableMove: false,
enableRemove: false,
cell: ({ row }): JSX.Element => (
<MapToBillingModelSelect
modelName={row.modelName}
disabled={!canManage}
onSelect={(rule): void => onRequestMap(row, rule)}
onCreateNew={(): void => onCreateNew(row.modelName)}
/>
),
},
];
}

View File

@@ -0,0 +1,4 @@
.spansBadge {
margin: 0;
font-family: var(--code-font-family, monospace);
}

View File

@@ -0,0 +1,22 @@
// Capped scroll viewport — mirrors ModelCostsTable so the unpriced tab doesn't
// shift on load, but uses max-height so a short list stays compact instead of
// reserving the full viewport. The extra offset over the model-costs table
// accounts for the banner toolbar above.
.unpricedModelsTable {
--tanstack-table-row-height: 48px;
max-height: calc(100vh - 220px);
overflow-y: auto;
:global(table) tbody tr {
cursor: default;
}
}
.unpricedModelsEmpty {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { SKELETON_ROW_COUNT } from 'container/LLMObservability/Settings/ModelPricing/constants';
import type { UnpricedModel } from 'container/LLMObservability/Settings/ModelPricing/types';
import styles from './UnpricedModelsTable.module.scss';
import {
getUnpricedModelsColumns,
type UnpricedColumnsConfig,
} from './TableConfig';
interface UnpricedModelsTableProps {
models: UnpricedModel[];
isLoading: boolean;
columnsConfig: UnpricedColumnsConfig;
}
// The unmapped-models endpoint returns the full set in one response, so there's
// no pagination here — just a content-height list. Virtual scroll is disabled
// because the set is small and bounded.
function UnpricedModelsTable({
models,
isLoading,
columnsConfig,
}: UnpricedModelsTableProps): JSX.Element {
const columns = useMemo(
() => getUnpricedModelsColumns(columnsConfig),
[columnsConfig],
);
if (!isLoading && models.length === 0) {
return (
<div
className={styles.unpricedModelsEmpty}
data-testid="unpriced-models-empty"
>
All models in your traces are priced.
</div>
);
}
return (
<TanStackTable<UnpricedModel>
className={styles.unpricedModelsTable}
data={models}
columns={columns}
isLoading={isLoading}
skeletonRowCount={SKELETON_ROW_COUNT}
getRowKey={(row): string => row.modelName}
disableVirtualScroll
testId="unpriced-models-table"
/>
);
}
export default UnpricedModelsTable;

View File

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

View File

@@ -0,0 +1,70 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
useCreateOrUpdateLLMPricingRules,
} from 'api/generated/services/llmpricingrules';
import type {
PricingRule,
UnpricedModel,
} from 'container/LLMObservability/Settings/ModelPricing/types';
import { buildPatternMappingPayload } from 'container/LLMObservability/Settings/ModelPricing/utils';
// A single row's choice: map this unpriced model onto this billing rule.
export interface UnpricedModelMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface UseUnpricedModelMappingResult {
// Commits a single mapping in one request. Resolves true on success so the
// caller can close the confirm dialog and clear its staged pick.
mapModel: (mapping: UnpricedModelMapping) => Promise<boolean>;
// True while the save is in flight, so the confirm button can spin.
isSaving: boolean;
}
// Maps an unpriced model onto an existing pricing rule. There's no dedicated
// "edit" endpoint — mapping reuses CreateOrUpdate (PUT), appending the model name
// as a match pattern on the chosen rule so the model inherits its pricing. Both
// the unmapped list and the rules list are invalidated on success so the mapped
// model drops out of this tab immediately.
export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
const queryClient = useQueryClient();
const [isSaving, setIsSaving] = useState(false);
const { mutateAsync: createOrUpdate } = useCreateOrUpdateLLMPricingRules();
const mapModel = useCallback(
async ({ model, rule }: UnpricedModelMapping): Promise<boolean> => {
const payload = buildPatternMappingPayload(rule, model.modelName);
setIsSaving(true);
try {
await createOrUpdate({ data: { rules: [payload] } });
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),
}),
queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
}),
]);
toast.success('Mapped model');
return true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Mapping failed';
toast.error(message);
return false;
} finally {
setIsSaving(false);
}
},
[createOrUpdate, queryClient],
);
return { mapModel, isSaving };
}

View File

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

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand';
import type { PricingRule, UnpricedModel } from '../types';
import { getRuleOptionLabel } from '../utils';
// A model + the billing rule it's about to be mapped onto, held while the confirm
// dialog is open.
export interface PendingMapping {
model: UnpricedModel;
rule: PricingRule;
}
interface PendingMappingState {
pending: PendingMapping | null;
setPending: (mapping: PendingMapping) => void;
clearPending: () => void;
}
// The staged mapping lives in a store (not component state/props) because the
// row's select trigger, which mirrors the staged pick, sits inside a memoized
// TanStackTable cell whose value is cached per row — a prop/context change would
// not re-render it, but a store subscription does. Picking a billing model stages
// a single mapping for confirmation; it only commits once confirmed in the dialog.
export const usePendingMappingStore = create<PendingMappingState>((set) => ({
pending: null,
setPending: (mapping): void => set({ pending: mapping }),
clearPending: (): void => set({ pending: null }),
}));
// Label to show on the given row's select trigger while its mapping is staged, or
// undefined when nothing is staged for that row (falls back to the placeholder).
export const usePendingMappingLabel = (modelName: string): string | undefined =>
usePendingMappingStore((state) => {
const { pending } = state;
if (!pending || pending.model.modelName !== modelName) {
return undefined;
}
return getRuleOptionLabel(pending.rule);
});

View File

@@ -2,14 +2,19 @@ import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
type ListLLMPricingRules200,
type ListUnmappedLLMModels200,
} from 'api/generated/services/sigNoz.schemas';
import type { PricingRule } from '../types';
import type { PricingRule, UnpricedModel } from '../types';
// Endpoint glob used by MSW handlers. The generated client hits a relative
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
// Distinct path (extra segment), so it needs its own handler — the list glob
// above does not match it.
export const LLM_UNMAPPED_ENDPOINT =
'*/api/v1/llm_pricing_rules/unmapped_models';
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
// replaced wholesale when provided so callers can shape cache buckets freely.
@@ -60,6 +65,26 @@ export const mockRules: PricingRule[] = [
}),
];
// Unpriced models seen in traces with no matching pricing rule.
export const mockUnpricedModels: UnpricedModel[] = [
{ modelName: 'gpt-4o-mini-2024-07-18', provider: 'openai', spanCount: 18400 },
{
modelName: 'claude-3-7-sonnet-20250219',
provider: 'anthropic',
spanCount: 9200,
},
];
// Wraps unpriced models in the envelope the unmapped-models query reads.
export function makeUnmappedResponse(
items: UnpricedModel[] = mockUnpricedModels,
): ListUnmappedLLMModels200 {
return {
status: 'success',
data: { items },
};
}
// Wraps items in the list response envelope the list query reads
// (`data.data.items` / `data.data.total`).
export function makeListResponse(

View File

@@ -34,6 +34,13 @@ export const SOURCE_FILTER_TO_IS_OVERRIDE: Record<
// loaded page renders — otherwise the table height jumps on load.
export const SKELETON_ROW_COUNT = PAGE_SIZE;
export const RULE_OPTIONS_LIMIT = 10;
// URL-backed key for the active tab on the model-pricing page.
export const TAB_KEY = 'tab';
export const MODEL_COSTS_TAB = 'model-costs';
export const UNPRICED_MODELS_TAB = 'unpriced-models';
export const PROVIDER_OPTIONS = [
{ value: 'OpenAI', label: 'OpenAI' },
{ value: 'Anthropic', label: 'Anthropic' },
@@ -62,6 +69,7 @@ export const EMPTY_DRAFT: DrawerDraft = {
provider: 'OpenAI',
patterns: [],
isOverride: true,
enabled: true,
pricing: {
input: null,
output: null,

View File

@@ -1,10 +1,14 @@
import {
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
type LlmpricingruletypesLLMPricingRuleDTO,
type LlmpricingruletypesUnmappedModelDTO,
} from 'api/generated/services/sigNoz.schemas';
export type PricingRule = LlmpricingruletypesLLMPricingRuleDTO;
// A model seen in trace data (gen_ai.request.model) that no pricing rule matches.
export type UnpricedModel = LlmpricingruletypesUnmappedModelDTO;
export interface ExtraBucket {
key: string;
pricePerMillion: number;
@@ -29,6 +33,7 @@ export interface DrawerDraft {
provider: string;
patterns: string[];
isOverride: boolean;
enabled: boolean;
pricing: {
input: number | null;
output: number | null;

View File

@@ -86,6 +86,7 @@ export const draftFromRule = (rule: PricingRule): DrawerDraft => ({
provider: rule.provider,
patterns: rule.modelPattern || [],
isOverride: !!rule.isOverride,
enabled: rule.enabled,
pricing: {
input: rule.pricing?.input ?? 0,
output: rule.pricing?.output ?? 0,
@@ -129,7 +130,7 @@ export const buildRulePayload = (
provider: draft.provider.trim(),
modelPattern: draft.patterns,
isOverride: draft.isOverride,
enabled: true,
enabled: draft.enabled,
unit: UnitDTO.per_million_tokens,
pricing: buildPricingPayload(draft),
});
@@ -161,3 +162,38 @@ export const validatePricing = (
}
return true;
};
const spanCountFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
maximumFractionDigits: 1,
});
export const formatSpanCount = (count: number): string =>
spanCountFormatter.format(count);
// Label for the "Map to billing model" dropdown, e.g. "openai:gpt-4o ($15.00/$60.00)".
export const getRuleOptionLabel = (rule: PricingRule): string =>
`${getCanonicalId(rule)} (${formatPricePerMillion(
rule.pricing?.input,
)}/${formatPricePerMillion(rule.pricing?.output)})`;
export const buildPatternMappingPayload = (
rule: PricingRule,
modelName: string,
): LlmpricingruletypesUpdatableLLMPricingRuleDTO => {
const existing = rule.modelPattern ?? [];
const modelPattern = existing.includes(modelName)
? existing
: [...existing, modelName];
return {
id: rule.id,
sourceId: rule.sourceId,
modelName: rule.modelName,
provider: rule.provider,
modelPattern,
isOverride: rule.isOverride,
enabled: rule.enabled,
unit: rule.unit,
pricing: rule.pricing,
};
};

View File

@@ -191,14 +191,6 @@
min-height: 0;
overflow-y: visible;
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
flex-shrink: 0;
}
.time-series-view {
flex-shrink: 0;
height: 65vh;

View File

@@ -32,7 +32,6 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -461,18 +460,17 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
onYAxisUnitChange={onUnitChange}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
allowExport
/>
</div>
)}

View File

@@ -311,6 +311,7 @@ function TimeSeries({
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
allowExport
/>
</div>
);

View File

@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
@@ -21,7 +21,7 @@ const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesRoleDTO };
| { type: 'role'; role: AuthtypesGettableRoleDTO };
interface RolesListContentProps {
searchQuery: string;
@@ -176,7 +176,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
<div
key={role.id}
className={cx(styles.tableRow, {

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import type {
AuthtypesPostableRoleDTO,
AuthtypesRoleWithTransactionGroupsDTO,
AuthtypesRoleDTO,
AuthtypesTransactionGroupDTO,
AuthtypesUpdatableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
}
export function transformApiToRolePermissions(
role: AuthtypesRoleWithTransactionGroupsDTO,
role: AuthtypesRoleDTO,
): RolePermissionsData {
return {
roleId: role.id,

View File

@@ -3,6 +3,14 @@
min-height: 350px;
padding: 0px 12px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
flex-shrink: 0;
}
.ant-card-body {
height: 50vh;
min-height: 350px;

View File

@@ -16,6 +16,7 @@ import Uplot from 'components/Uplot';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
@@ -41,11 +42,14 @@ import { SuccessResponse, Warning } from 'types/api';
import { LegendPosition } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import TimeseriesExportMenu from './TimeseriesExportMenu';
import './TimeSeriesView.styles.scss';
function TimeSeriesView({
@@ -59,6 +63,8 @@ function TimeSeriesView({
setWarning,
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -244,10 +250,33 @@ function TimeSeriesView({
[baseChartOptions, stackedBands],
);
const showExport = allowExport && !!data?.rawV5Response;
const showHeader = showExport || !!onYAxisUnitChange;
return (
<div className="time-series-view">
{isError && error && <ErrorInPlace error={error as APIError} />}
{showHeader && (
<div className="time-series-view__header">
<div>
{onYAxisUnitChange && (
<BuilderUnitsFilter onChange={onYAxisUnitChange} yAxisUnit={yAxisUnit} />
)}
</div>
{showExport && data?.rawV5Response && (
<TimeseriesExportMenu
dataSource={dataSource}
yAxisUnit={yAxisUnit}
queryResponse={data.rawV5Response}
query={currentQuery}
legendMap={data.legendMap}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
)}
<div
className="graph-container"
style={{ height: '100%', width: '100%' }}
@@ -295,7 +324,11 @@ function TimeSeriesView({
}
interface TimeSeriesViewProps {
data?: SuccessResponse<MetricRangePayloadProps> & { warning?: Warning };
data?: SuccessResponse<MetricRangePayloadProps> & {
warning?: Warning;
rawV5Response?: QueryRangeResponseV5;
legendMap?: Record<string, string>;
};
yAxisUnit?: string;
isLoading: boolean;
isError: boolean;
@@ -305,6 +338,11 @@ interface TimeSeriesViewProps {
setWarning?: Dispatch<SetStateAction<Warning | undefined>>;
panelType?: PANEL_TYPES;
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
}
TimeSeriesView.defaultProps = {

View File

@@ -0,0 +1,33 @@
.timeseries-export-popover {
width: 240px;
padding: 0 12px 12px 12px;
.title {
display: flex;
color: var(--l1-foreground);
font-family: Inter;
font-size: var(--periscope-font-size-small);
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format {
padding: 12px 4px;
display: flex;
flex-direction: column;
// radio option labels — radix popover content inherits the root font
// size; pin to the app's 13px base the antd popover used to impose
label {
font-size: var(--periscope-font-size-base);
}
}
.export-button {
width: 100%;
}
}

View File

@@ -0,0 +1,91 @@
import { Download } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { useClientExport } from 'hooks/useExportData/useClientExport';
import { ExportFormat } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import './TimeseriesExportMenu.styles.scss';
interface TimeseriesExportMenuProps {
dataSource: DataSource;
queryResponse: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
legendMap?: Record<string, string>;
fileName?: string;
}
// Download menu for in-memory timeseries data (client-side serialization).
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
export default function TimeseriesExportMenu({
dataSource,
queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
}: TimeseriesExportMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const { isExporting, handleExport: handleClientExport } = useClientExport({
response: queryResponse,
query,
yAxisUnit,
legendMap,
fileName,
});
const handleExport = useCallback((): void => {
setIsPopoverOpen(false);
handleClientExport({ format: exportFormat as ExportFormat });
}, [exportFormat, handleClientExport]);
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipSimple title="Download">
<PopoverTrigger asChild>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Download"
data-testid={`timeseries-export-${dataSource}`}
disabled={isExporting}
loading={isExporting}
>
<Download size={14} />
</Button>
</PopoverTrigger>
</TooltipSimple>
<PopoverContent align="end" className="timeseries-export-popover">
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<RadioGroup value={exportFormat} onChange={setExportFormat}>
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
</RadioGroup>
</div>
<Button
variant="solid"
color="primary"
className="export-button"
onClick={handleExport}
disabled={isExporting}
loading={isExporting}
prefix={<Download size={16} />}
>
Export
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,138 @@
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import store from 'store';
import { DataSource } from 'types/common/queryBuilder';
import TimeSeriesView from '../TimeSeriesView';
jest.mock('components/Uplot', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="uplot-chart" />,
}));
jest.mock('../TimeseriesExportMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
}));
jest.mock('container/QueryBuilder/filters/BuilderUnitsFilter', () => ({
BuilderUnitsFilter: (): JSX.Element => (
<div data-testid="builder-units-filter" />
),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: null }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: (): unknown => ({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: (): number[][] => [
[1, 2],
[3, 4],
],
}));
jest.mock(
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
() => ({ stackSeries: (): unknown => ({ data: [], bands: [] }) }),
);
jest.mock('container/GridCardLayout/GridCard/utils', () => ({
getLocalStorageGraphVisibilityState: (): unknown => ({
graphVisibilityStates: [],
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): unknown => ({ timezone: { value: 'UTC' } }),
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): unknown => ({ width: 800, height: 400 }),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockStore = configureStore([])({ ...store.getState() });
const rawV5Response = {
type: 'time_series',
data: { results: [] },
meta: {},
};
function makeData(withRawV5: boolean): any {
return {
statusCode: 200,
error: null,
message: '',
payload: { data: { result: [], resultType: '' } },
...(withRawV5 ? { rawV5Response, legendMap: {} } : {}),
};
}
function renderView(props: {
allowExport?: boolean;
withRawV5?: boolean;
onYAxisUnitChange?: (value: string) => void;
}): ReturnType<typeof render> {
const { allowExport, withRawV5 = true, onYAxisUnitChange } = props;
return render(
<Provider store={mockStore}>
<MemoryRouter>
<TimeSeriesView
isLoading={false}
isError={false}
isFilterApplied
dataSource={DataSource.LOGS}
data={makeData(withRawV5)}
allowExport={allowExport}
onYAxisUnitChange={onYAxisUnitChange}
/>
</MemoryRouter>
</Provider>,
);
}
describe('TimeSeriesView header gating', () => {
it('renders the export menu when allowExport is set and raw V5 data is present', () => {
const { queryByTestId } = renderView({ allowExport: true });
expect(queryByTestId('timeseries-export-menu')).toBeInTheDocument();
});
it('renders no export menu without allowExport', () => {
const { queryByTestId } = renderView({});
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders no export menu when the raw V5 response is missing', () => {
const { queryByTestId } = renderView({ allowExport: true, withRawV5: false });
expect(queryByTestId('timeseries-export-menu')).not.toBeInTheDocument();
});
it('renders the unit selector only when onYAxisUnitChange is passed', () => {
const withUnit = renderView({ onYAxisUnitChange: jest.fn() });
expect(withUnit.queryByTestId('builder-units-filter')).toBeInTheDocument();
withUnit.unmount();
const withoutUnit = renderView({ allowExport: true });
expect(
withoutUnit.queryByTestId('builder-units-filter'),
).not.toBeInTheDocument();
});
it('renders no header row when neither export nor unit selector is enabled', () => {
const { container } = renderView({ withRawV5: false });
expect(container.querySelector('.time-series-view__header')).toBeNull();
});
});

View File

@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import TimeseriesExportMenu from '../TimeseriesExportMenu';
const mockHandleExport = jest.fn();
let mockIsExporting = false;
jest.mock('hooks/useExportData/useClientExport', () => ({
useClientExport: (): unknown => ({
isExporting: mockIsExporting,
handleExport: mockHandleExport,
}),
}));
const response = {
type: 'time_series',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
function renderMenu(): void {
render(
<TimeseriesExportMenu
dataSource={DataSource.LOGS}
queryResponse={response}
fileName="logs-timeseries"
/>,
);
}
describe('TimeseriesExportMenu', () => {
beforeEach(() => {
mockHandleExport.mockReset();
mockIsExporting = false;
});
it('renders the download trigger button', () => {
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
});
it('shows only format options — no shape, row-count, or column controls', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'csv' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'jsonl' })).toBeInTheDocument();
expect(screen.queryByText('Number of Rows')).not.toBeInTheDocument();
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'long' })).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'wide' })).not.toBeInTheDocument();
});
it('exports as csv by default', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledTimes(1);
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'csv' });
});
it('exports as jsonl when selected', () => {
renderMenu();
fireEvent.click(screen.getByTestId(TEST_ID));
fireEvent.click(screen.getByRole('radio', { name: 'jsonl' }));
fireEvent.click(screen.getByText('Export'));
expect(mockHandleExport).toHaveBeenCalledWith({ format: 'jsonl' });
});
it('disables the trigger while an export is in progress', () => {
mockIsExporting = true;
renderMenu();
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
});
});

View File

@@ -1,10 +1,41 @@
import { SuccessResponse } from 'types/api/index';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
type ConvertibleData = SuccessResponse<MetricRangePayloadProps> & {
rawV5Response?: QueryRangeResponseV5;
};
// Applies the same ns→ms conversion to the raw V5 tree, so client-side export
// serializes the values the chart displays (not the original nanoseconds).
function convertRawV5ValuesToMs(
response: QueryRangeResponseV5,
): QueryRangeResponseV5 {
if (response.type !== 'time_series') {
return response;
}
const results = (response.data.results as TimeSeriesData[]).map((result) => ({
...result,
aggregations: (result.aggregations ?? []).map((bucket) => ({
...bucket,
series: (bucket.series ?? []).map((series) => ({
...series,
values: (series.values ?? []).map((value) => ({
...value,
value: value.value / 1000000,
})),
})),
})),
}));
return { ...response, data: { ...response.data, results } };
}
export const convertDataValueToMs = (
data?: SuccessResponse<MetricRangePayloadProps>,
): SuccessResponse<MetricRangePayloadProps> | undefined => {
data?: ConvertibleData,
): ConvertibleData | undefined => {
const convertedData = data;
const convertedResult: QueryData[] = data?.payload?.data?.result
@@ -22,5 +53,11 @@ export const convertDataValueToMs = (
convertedData.payload.data.result = convertedResult;
}
if (convertedData?.rawV5Response) {
convertedData.rawV5Response = convertRawV5ValuesToMs(
convertedData.rawV5Response,
);
}
return convertedData;
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
}
interface UseMemberRoleManagerResult {
currentRoles: AuthtypesRoleDTO[];
currentRoles: AuthtypesGettableRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
) => Promise<MemberRoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
{ query: { enabled: !!userId && enabled } },
);
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
): Promise<MemberRoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -6,7 +6,7 @@ import {
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
}
interface UseServiceAccountRoleManagerResult {
currentRoles: AuthtypesRoleDTO[];
currentRoles: AuthtypesGettableRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
) => Promise<RoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
{ query: { enabled: options?.enabled ?? true } },
);
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesRoleDTO[],
availableRoles: AuthtypesGettableRoleDTO[],
): Promise<RoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -4,6 +4,8 @@ export function useIntersectionObserver<T extends HTMLElement>(
ref: RefObject<T>,
options?: IntersectionObserverInit,
isObserverOnce?: boolean,
/** Defer observation by this many ms to let a transient mount layout settle. */
startDelayMs = 0,
): boolean {
const [isIntersecting, setIntersecting] = useState(false);
@@ -23,16 +25,28 @@ export function useIntersectionObserver<T extends HTMLElement>(
}
}, options);
if (currentReference) {
observer.observe(currentReference);
const startObserving = (): void => {
if (currentReference) {
observer.observe(currentReference);
}
};
let timer: ReturnType<typeof setTimeout> | undefined;
if (startDelayMs > 0) {
timer = setTimeout(startObserving, startDelayMs);
} else {
startObserving();
}
return (): void => {
if (timer) {
clearTimeout(timer);
}
if (currentReference) {
observer.unobserve(currentReference);
}
};
}, [ref, options, isObserverOnce]);
}, [ref, options, isObserverOnce, startDelayMs]);
return isIntersecting;
}

View File

@@ -22,7 +22,11 @@ import { SuccessResponseV2, Warning } from 'types/api';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { ExecStats, MetricRangePayloadV5 } from 'types/api/v5/queryRange';
import {
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
} from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -192,6 +196,8 @@ export async function GetMetricQueryRange(
| SuccessResponseV2<MetricRangePayloadV5>;
let warning: Warning | undefined;
let meta: ExecStats | undefined;
// Raw V5 response, kept before it's converted to legacy — powers client-side export.
let rawV5Response: QueryRangeResponseV5 | undefined;
const panelType = props.originalGraphType || props.graphType;
@@ -268,6 +274,8 @@ export async function GetMetricQueryRange(
endTime: props.end * 1000,
});
rawV5Response = publicResponse.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -288,6 +296,8 @@ export async function GetMetricQueryRange(
headers,
);
rawV5Response = v5Response.data.data;
// Convert V5 response to legacy format for components
response = convertV5ResponseToLegacy(
{
@@ -366,6 +376,8 @@ export async function GetMetricQueryRange(
...response,
warning,
meta,
rawV5Response,
legendMap,
};
}

View File

@@ -3,6 +3,7 @@ import { EQueryType } from 'types/common/dashboard';
import {
buildVariableReferencePattern,
containsAnyVariableReference,
extractQueryTextStrings,
getVariableReferencesInQuery,
textContainsVariableReference,
@@ -448,3 +449,25 @@ describe('getVariableReferencesInQuery', () => {
expect(getVariableReferencesInQuery(query, [])).toStrictEqual([]);
});
});
describe('containsAnyVariableReference', () => {
it.each([
['SELECT count() FROM t WHERE service = $service.name', true],
['up{env="$deployment_environment"}', true],
['{{.service_name}}', true],
['{{ service_name }}', true],
['[[service_name]]', true],
['$_private', true],
])('detects a reference in %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
it.each([
['SELECT count() FROM t WHERE x = 1', false],
['rate(http_requests[$__interval])', false],
['SELECT $1 FROM t', false],
['', false],
])('does not falsely match %p', (text, expected) => {
expect(containsAnyVariableReference(text)).toBe(expected);
});
});

View File

@@ -1,4 +1,4 @@
import { isArray } from 'lodash-es';
import { escapeRegExp, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -33,6 +33,23 @@ export function textContainsVariableReference(
return buildVariableReferencePattern(variableName).test(text);
}
/**
* Matches *any* variable reference in a recognized syntax without knowing the
* name: `{{name}}`, `{{.name}}`, `[[name]]`, or `$name`. The `$` form excludes
* `$__…` macros and positional `$1` so built-ins don't read as variables.
*/
const ANY_VARIABLE_REFERENCE =
/\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$(?!__)[a-zA-Z_][\w.]*/;
/**
* Returns true if `text` contains a reference to any variable. Use when the set
* of variable names isn't known yet (e.g. before the fetch context initializes),
* so a name-based {@link textContainsVariableReference} check can't run.
*/
export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
@@ -134,3 +151,52 @@ export function getVariableReferencesInQuery(
texts.some((text) => textContainsVariableReference(text, name)),
);
}
/**
* Rewrites every reference to `oldName` in `text` to `newName`, preserving the
* surrounding syntax for each recognized form ({{.x}}, {{x}}, $x, [[x]]). Used
* when a variable is renamed so its usages across queries stay valid.
*/
export function rewriteVariableReferences(
text: string,
oldName: string,
newName: string,
): string {
if (!text || !oldName || oldName === newName) {
return text;
}
const name = escapeRegExp(oldName);
return text
.replace(
new RegExp(`(\\{\\{\\s*?\\.)${name}(\\s*?\\}\\})`, 'g'),
`$1${newName}$2`,
)
.replace(new RegExp(`(\\{\\{\\s*)${name}(\\s*\\}\\})`, 'g'), `$1${newName}$2`)
.replace(new RegExp(`\\$${name}\\b`, 'g'), `$${newName}`)
.replace(
new RegExp(`(\\[\\[\\s*)${name}(\\s*\\]\\])`, 'g'),
`$1${newName}$2`,
);
}
/**
* Best-effort removal of the clause that references `variableName` from an
* ` AND `-joined filter expression (e.g. a builder query's `filter.expression`).
* Any top-level `AND` part that references the variable is dropped. It does not
* understand `OR`/nested parentheses, so it is a starting point the user reviews
* before applying — never an automatic edit of raw PromQL/ClickHouse.
*/
export function removeVariableReferenceClause(
expression: string,
variableName: string,
): string {
if (!expression) {
return expression;
}
return expression
.split(' AND ')
.map((part) => part.trim())
.filter(Boolean)
.filter((part) => !textContainsVariableReference(part, variableName))
.join(' AND ');
}

View File

@@ -81,6 +81,18 @@ describe('exportTimeseriesData', () => {
]);
});
it('omits display-only format ids (short/none) from headers', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'a' }, [[1000, 1]])] }]),
];
const short = exportTimeseriesData({ data, yAxisUnit: 'short' });
expect(short.headers[short.headers.length - 1]).toBe('value');
const none = exportTimeseriesData({ data, yAxisUnit: 'none' });
expect(none.headers[none.headers.length - 1]).toBe('value');
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),

View File

@@ -98,9 +98,16 @@ function flatten(
return flat;
}
// Display-format ids, not physical units — meaningful on a chart axis
// (compact-number formatting) but misleading in an export header.
const DISPLAY_ONLY_UNITS = new Set(['short', 'none']);
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
if (!yAxisUnit || DISPLAY_ONLY_UNITS.has(yAxisUnit)) {
return header;
}
return `${header} (${yAxisUnit})`;
}
function toIso(timestamp: number): string {

View File

@@ -1,8 +1,11 @@
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesGettableRoleDTO,
AuthtypesRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: AuthtypesRoleDTO[] = [
export const managedRoles: AuthtypesGettableRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: '2026-02-03T18:00:55.624356Z',
@@ -35,7 +38,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
},
];
export const customRoles: AuthtypesRoleDTO[] = [
export const customRoles: AuthtypesGettableRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: '2026-02-10T10:30:00.000Z',
@@ -56,12 +59,24 @@ export const customRoles: AuthtypesRoleDTO[] = [
},
];
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
export const allRoles: AuthtypesGettableRoleDTO[] = [
...managedRoles,
...customRoles,
];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};
export const customRoleResponse = { status: 'success', data: customRoles[0] };
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
const customRole: AuthtypesRoleDTO = {
...customRoles[0],
transactionGroups: [],
};
const managedRole: AuthtypesRoleDTO = {
...managedRoles[0],
transactionGroups: [],
};
export const customRoleResponse = { status: 'success', data: customRole };
export const managedRoleResponse = { status: 'success', data: managedRole };

View File

@@ -0,0 +1,86 @@
.body {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
}
.intro {
color: var(--l2-foreground);
font-size: 13px;
line-height: 1.5;
}
.rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l1-background);
}
.rowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sourceLabel {
color: var(--l1-foreground);
font-weight: 600;
font-size: 13px;
}
.kindTag {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
white-space: nowrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: var(--l2-foreground);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.textArea {
font-family: var(--font-mono, monospace);
font-size: 12px;
}
.disabled {
opacity: 0.6;
}
.warning {
color: var(--warning-foreground, #d97706);
font-size: 11px;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,159 @@
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea + Checkbox have no @signozhq/ui equivalent yet
import { Checkbox, Input as AntdInput } from 'antd';
import cx from 'classnames';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableImpactMode, VariableUsage } from '../variableUsages';
import { useVariableImpactState } from './useVariableImpactState';
import styles from './VariableImpactDialog.module.scss';
const KIND_LABEL: Record<VariableUsage['kind'], string> = {
builder: 'Query builder',
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
};
interface VariableImpactDialogProps {
open: boolean;
mode: VariableImpactMode;
/** The variable being renamed/deleted (its current name). */
variableName: string;
/** The new name (rename mode only). */
newName?: string;
usages: VariableUsage[];
isLoading: boolean;
onConfirm: (resolvedUsages: VariableUsage[]) => void;
onClose: () => void;
}
/**
* Blocks a rename/delete of a referenced variable behind a review step: lists
* every usage across panel queries (builder / PromQL / ClickHouse) and other
* variables, shows the current vs resulting query, and lets the user edit each
* result or exclude it before applying.
*/
function VariableImpactDialog({
open,
mode,
variableName,
newName,
usages,
isLoading,
onConfirm,
onClose,
}: VariableImpactDialogProps): JSX.Element {
const { rows, setResultingText, toggleIncluded, resolvedUsages } =
useVariableImpactState(usages, open);
const isRename = mode === 'rename';
const count = usages.length;
const plural = count === 1 ? '' : 's';
const intro = isRename
? `$${variableName} is used in ${count} place${plural}. Review the updated queries before renaming to $${newName}.`
: `$${variableName} is used in ${count} place${plural}. Edit or remove each usage before deleting.`;
const footer = (
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
onClick={onClose}
testId="variable-impact-cancel"
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color={isRename ? 'primary' : 'destructive'}
loading={isLoading}
onClick={(): void => onConfirm(resolvedUsages)}
testId="variable-impact-confirm"
>
<Check size={12} />
{isRename ? 'Rename' : 'Delete'}
</Button>
</div>
);
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={isRename ? `Rename $${variableName}` : `Delete $${variableName}`}
width="wide"
showCloseButton={false}
// Lift above the settings drawer (z ~1000); overlay off (it would only half-dim).
style={{ zIndex: 1100 }}
showOverlay={false}
footer={footer}
>
<div className={styles.body}>
<Typography.Text className={styles.intro}>{intro}</Typography.Text>
<div className={styles.rows}>
{rows.map((row) => {
const stillReferences =
row.included &&
textContainsVariableReference(row.resultingText, variableName);
return (
<div
key={row.id}
className={styles.row}
data-testid={`variable-impact-row-${row.id}`}
>
<div className={styles.rowHeader}>
<Checkbox
checked={row.included}
onChange={(): void => toggleIncluded(row.id)}
data-testid={`variable-impact-include-${row.id}`}
>
<span className={styles.sourceLabel}>{row.sourceLabel}</span>
</Checkbox>
<span className={styles.kindTag}>{KIND_LABEL[row.kind]}</span>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>
Current
</Typography.Text>
<AntdInput.TextArea
className={styles.textArea}
value={row.currentText}
readOnly
autoSize={{ minRows: 1, maxRows: 4 }}
/>
</div>
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Result</Typography.Text>
<AntdInput.TextArea
className={cx(styles.textArea, !row.included && styles.disabled)}
value={row.resultingText}
disabled={!row.included}
autoSize={{ minRows: 1, maxRows: 4 }}
onChange={(e): void => setResultingText(row.id, e.target.value)}
data-testid={`variable-impact-result-${row.id}`}
/>
{stillReferences ? (
<Typography.Text className={styles.warning}>
Still references ${variableName}
</Typography.Text>
) : null}
</div>
</div>
);
})}
</div>
</div>
</DialogWrapper>
);
}
export default VariableImpactDialog;

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react';
import type { VariableUsage } from '../variableUsages';
/** A usage row plus whether its edit will be applied on confirm. */
export interface EditableVariableUsage extends VariableUsage {
included: boolean;
}
interface UseVariableImpactState {
rows: EditableVariableUsage[];
setResultingText: (id: string, text: string) => void;
toggleIncluded: (id: string) => void;
/** The included rows, as plain usages, to build the patch from. */
resolvedUsages: VariableUsage[];
}
/**
* Editable state for the impact dialog: a per-usage copy the user can edit
* (`resultingText`) and include/exclude before applying. Resets whenever the
* dialog (re)opens for a fresh usage set.
*/
export function useVariableImpactState(
usages: VariableUsage[],
open: boolean,
): UseVariableImpactState {
const [rows, setRows] = useState<EditableVariableUsage[]>([]);
useEffect(() => {
if (open) {
setRows(usages.map((usage) => ({ ...usage, included: true })));
}
}, [open, usages]);
const setResultingText = useCallback((id: string, text: string): void => {
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, resultingText: text } : row)),
);
}, []);
const toggleIncluded = useCallback((id: string): void => {
setRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, included: !row.included } : row,
),
);
}, []);
const resolvedUsages: VariableUsage[] = rows.filter((row) => row.included);
return { rows, setResultingText, toggleIncluded, resolvedUsages };
}

View File

@@ -0,0 +1,98 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { findVariableUsages } from '../variableUsages';
// Identity adapter so `spec.variables` can be plain form models in the test.
jest.mock('../variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function variable(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
function builderPanel(name: string, expression: string): unknown {
return {
spec: {
display: { name },
queries: [
{
spec: {
plugin: { kind: 'signoz/BuilderQuery', spec: { filter: { expression } } },
},
},
],
},
};
}
function promqlPanel(name: string, query: string): unknown {
return {
spec: {
display: { name },
queries: [
{ spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { query } } } },
],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
): DashboardtypesGettableDashboardV2DTO {
return {
spec: { panels, variables },
} as unknown as DashboardtypesGettableDashboardV2DTO;
}
describe('findVariableUsages', () => {
const dash = dashboard(
{
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
p3: builderPanel('Unrelated', "env = 'prod'"),
},
[
variable({ name: 'svc', type: 'QUERY' }),
variable({
name: 'other',
type: 'QUERY',
queryValue: 'SELECT x WHERE s = $svc',
}),
variable({ name: 'plain', type: 'QUERY', queryValue: 'SELECT y' }),
],
);
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const ids = usages.map((u) => u.id).sort();
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
});
it('rewrites references for a rename across all kinds', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
});
it('strips builder clauses on delete but leaves raw/variable queries for review', () => {
const usages = findVariableUsages(dash, 'svc', 'delete');
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
// Builder: the clause referencing $svc is dropped.
expect(byId['panel:p1:0']).toBe("env = 'prod'");
// Raw PromQL + variable query: unchanged (user edits).
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
});
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
});

View File

@@ -17,7 +17,17 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableFormModel';
import {
applyVariableQueryEdits,
buildVariableImpactPatch,
} from './variableImpactPatch';
import {
findVariableUsages,
type VariableImpactMode,
type VariableUsage,
} from './variableUsages';
import VariableForm from './VariableForm/VariableForm';
import VariableImpactDialog from './VariableImpactDialog/VariableImpactDialog';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
import AddVariableButton from './components/AddVariableButton';
@@ -59,6 +69,16 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
null,
);
const [applyToAllIndex, setApplyToAllIndex] = useState<number | null>(null);
// A pending rename/delete that touches other queries — resolved via the impact
// dialog before it is applied. `nextVariables` is the array to persist (with the
// rename/delete already applied), before any variable-query edits.
const [impact, setImpact] = useState<{
mode: VariableImpactMode;
variableName: string;
newName?: string;
usages: VariableUsage[];
nextVariables: VariableFormModel[];
} | null>(null);
const editingFormModel: VariableFormModel | null = useMemo(() => {
if (!isEditing) {
@@ -104,12 +124,38 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
formModel: VariableFormModel,
selectedPanelIds: string[],
): void => {
const editingIndex = isEditing?.type === 'edit' ? isEditing.index : null;
const oldName = editingIndex !== null ? variables[editingIndex].name : null;
const next = [...variables];
if (isEditing?.type === 'new') {
next.push(formModel);
} else if (isEditing?.type === 'edit') {
next[isEditing.index] = formModel;
} else if (editingIndex !== null) {
next[editingIndex] = formModel;
}
// A rename that other queries/variables reference must be reviewed first, so
// the references are rewritten alongside the rename (never left dangling).
if (oldName && oldName !== formModel.name) {
const usages = findVariableUsages(
dashboard,
oldName,
'rename',
formModel.name,
);
if (usages.length > 0) {
setIsEditing(null);
setImpact({
mode: 'rename',
variableName: oldName,
newName: formModel.name,
usages,
nextVariables: next,
});
return;
}
}
setIsEditing(null);
setVariables(next);
void (async (): Promise<void> => {
@@ -149,6 +195,57 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
setConfirmDeleteIndex(null);
};
// Delete requested from the list: if the variable is referenced anywhere, block
// and open the impact dialog; otherwise fall through to the simple confirm.
const requestDelete = (index: number): void => {
const usages = findVariableUsages(dashboard, variables[index].name, 'delete');
if (usages.length > 0) {
setImpact({
mode: 'delete',
variableName: variables[index].name,
usages,
nextVariables: variables.filter((_, i) => i !== index),
});
return;
}
setConfirmDeleteIndex(index);
};
// Applies a resolved rename/delete: the variables array (rename/delete + edited
// variable queries) and each touched panel's queries, in one atomic patch.
const handleImpactConfirm = async (
resolvedUsages: VariableUsage[],
): Promise<void> => {
if (!impact) {
return;
}
const nextVariables = applyVariableQueryEdits(
impact.nextVariables,
resolvedUsages,
);
const ops = buildVariableImpactPatch(
dashboard,
nextVariables,
resolvedUsages,
);
setVariables(nextVariables);
try {
await patchAsync(ops);
toast.success(
impact.mode === 'rename'
? `Renamed to $${impact.newName}`
: `Deleted $${impact.variableName}`,
);
} catch {
toast.error(
impact.mode === 'rename'
? 'Could not rename the variable'
: 'Could not delete the variable',
);
}
setImpact(null);
};
const applyToAllVariable =
applyToAllIndex === null ? null : variables[applyToAllIndex];
@@ -202,7 +299,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onRequestDelete={requestDelete}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
@@ -220,6 +317,16 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
onConfirm={(): void => void handleConfirmApplyToAll()}
onClose={(): void => setApplyToAllIndex(null)}
/>
<VariableImpactDialog
open={impact !== null}
mode={impact?.mode ?? 'delete'}
variableName={impact?.variableName ?? ''}
newName={impact?.newName}
usages={impact?.usages ?? []}
isLoading={isPatching}
onConfirm={(resolved): void => void handleImpactConfirm(resolved)}
onClose={(): void => setImpact(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesJSONPatchOperationDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { cloneDeep } from 'lodash-es';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableFormModel';
import { buildVariablesPatch } from './variablePatchOps';
import type { VariableUsage, VariableUsageKind } from './variableUsages';
/** Minimal writable view of an envelope spec's reference-bearing fields. */
interface WritableSpec {
query?: string;
filter?: { expression?: string };
}
/** Writes the resolved text into the spec's builder filter or raw query field. */
function writeSpecText(
spec: WritableSpec,
kind: VariableUsageKind,
text: string,
): void {
if (kind === 'builder') {
spec.filter = { ...(spec.filter ?? {}), expression: text };
} else {
spec.query = text;
}
}
/** Applies one panel usage's edited text into a (cloned) queries array in place. */
function applyPanelUsage(
queries: DashboardtypesQueryDTO[],
usage: VariableUsage,
): void {
const plugin = queries[0]?.spec?.plugin;
if (!plugin?.spec) {
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
const envelope = (composite.queries ?? [])[usage.envelopeIndex];
if (envelope?.spec) {
writeSpecText(
envelope.spec as WritableSpec,
usage.kind,
usage.resultingText,
);
}
} else {
// Bare BuilderQuery / PromQLQuery / ClickHouseSQL — the plugin spec is the
// single envelope (index 0).
writeSpecText(plugin.spec as WritableSpec, usage.kind, usage.resultingText);
}
}
/**
* Applies the variable-definition usages' edited text back into the matching
* variable's `queryValue`, so a renamed/deleted variable's references inside
* another query variable are updated alongside the panels.
*/
export function applyVariableQueryEdits(
variables: VariableFormModel[],
usages: VariableUsage[],
): VariableFormModel[] {
const edits = new Map(
usages
.filter((usage) => usage.sourceType === 'variable')
.map((usage) => [usage.sourceId, usage.resultingText]),
);
if (edits.size === 0) {
return variables;
}
return variables.map((variable) =>
edits.has(variable.name)
? { ...variable, queryValue: edits.get(variable.name) as string }
: variable,
);
}
/**
* Builds the atomic JSON-Patch for a variable rename/delete impact: replaces the
* whole variables array (which the caller has already updated for the rename/
* delete and any variable-query edits) and replaces each touched panel's queries
* with the user's resolved text.
*/
export function buildVariableImpactPatch(
dashboard: DashboardtypesGettableDashboardV2DTO,
nextVariables: VariableFormModel[],
usages: VariableUsage[],
): DashboardtypesJSONPatchOperationDTO[] {
const ops: DashboardtypesJSONPatchOperationDTO[] = [
...buildVariablesPatch(nextVariables.map(formModelToDto)),
];
const panels = dashboard.spec.panels ?? {};
const byPanel = new Map<string, VariableUsage[]>();
usages
.filter((usage) => usage.sourceType === 'panel')
.forEach((usage) => {
const list = byPanel.get(usage.sourceId) ?? [];
list.push(usage);
byPanel.set(usage.sourceId, list);
});
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
if (!panel?.spec?.queries?.length) {
return;
}
const queries = cloneDeep(panel.spec.queries);
list.forEach((usage) => applyPanelUsage(queries, usage));
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/queries`,
value: queries,
});
});
return ops;
}

View File

@@ -0,0 +1,163 @@
import type {
DashboardtypesGettableDashboardV2DTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
removeVariableReferenceClause,
rewriteVariableReferences,
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
import { dtoToFormModel } from './variableAdapters';
/** The kind of query text a variable is referenced from. */
export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
/** Whether the impact is a rename (rewrite refs) or a delete (remove refs). */
export type VariableImpactMode = 'rename' | 'delete';
/**
* One place a variable is referenced — a panel query's builder filter expression,
* a PromQL/ClickHouse query string, or another variable's query definition. Each
* usage is a single editable text field: `currentText` is what exists today,
* `resultingText` is the proposed rewrite (rename) or removal (delete) the user
* can review and edit before applying.
*/
export interface VariableUsage {
/** Stable key: `${sourceType}:${sourceId}:${envelopeIndex}`. */
id: string;
sourceType: 'panel' | 'variable';
/** Panel id or referencing variable's name. */
sourceId: string;
/** Human label: panel display name or `$variableName`. */
sourceLabel: string;
kind: VariableUsageKind;
/** Index into the panel's query envelopes (0 for a variable definition). */
envelopeIndex: number;
currentText: string;
resultingText: string;
}
/** The reference-bearing text + kind for one query envelope, if any. */
function envelopeReferenceText(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): { kind: VariableUsageKind; text: string } | null {
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
if (envelope.type === 'promql') {
return typeof spec?.query === 'string'
? { kind: 'promql', text: spec.query }
: null;
}
if (envelope.type === 'clickhouse_sql') {
return typeof spec?.query === 'string'
? { kind: 'clickhouse', text: spec.query }
: null;
}
return null;
}
/** The proposed text after a rename (rewrite) or delete (best-effort removal). */
function computeResultingText(
kind: VariableUsageKind,
text: string,
variableName: string,
mode: VariableImpactMode,
newName: string,
): string {
if (mode === 'rename') {
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableReferenceClause(text, variableName)
: text;
}
/**
* Finds every usage of `variableName` across the dashboard's panel queries
* (builder / PromQL / ClickHouse) and other variables' query definitions, with a
* proposed `resultingText` for the given mode. Consumed by the impact dialog that
* blocks a rename/delete until the user resolves each usage.
*/
export function findVariableUsages(
dashboard: DashboardtypesGettableDashboardV2DTO,
variableName: string,
mode: VariableImpactMode,
newName = '',
): VariableUsage[] {
if (!variableName) {
return [];
}
const usages: VariableUsage[] = [];
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;
}
toQueryEnvelopes(queries).forEach((envelope, index) => {
const ref = envelopeReferenceText(envelope);
if (!ref || !textContainsVariableReference(ref.text, variableName)) {
return;
}
usages.push({
id: `panel:${panelId}:${index}`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: ref.kind,
envelopeIndex: index,
currentText: ref.text,
resultingText: computeResultingText(
ref.kind,
ref.text,
variableName,
mode,
newName,
),
});
});
});
(spec.variables ?? []).map(dtoToFormModel).forEach((variable) => {
if (
variable.name === variableName ||
variable.type !== 'QUERY' ||
!variable.queryValue ||
!textContainsVariableReference(variable.queryValue, variableName)
) {
return;
}
usages.push({
id: `variable:${variable.name}:0`,
sourceType: 'variable',
sourceId: variable.name,
sourceLabel: `$${variable.name}`,
kind: 'variable',
envelopeIndex: 0,
currentText: variable.queryValue,
resultingText: computeResultingText(
'variable',
variable.queryValue,
variableName,
mode,
newName,
),
});
});
return usages;
}

View File

@@ -92,6 +92,7 @@ function Panel({
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}

View File

@@ -23,6 +23,8 @@ interface PanelBodyProps {
panelId: string;
data: PanelQueryData;
isFetching: boolean;
/** Panel not yet scrolled into view — its fetch is deferred, so show the loader rather than NoData. */
isVisible?: boolean;
/** Showing a prior page's data while the next loads; forwarded so list renderers can show skeletons. */
isPreviousData?: boolean;
error: Error | null;
@@ -54,6 +56,7 @@ function PanelBody({
panelId,
data,
isFetching,
isVisible,
isPreviousData,
error,
refetch,
@@ -105,9 +108,9 @@ function PanelBody({
);
}
// Full-panel loader only on first fetch; a refetch over existing data keeps the renderer
// mounted (e.g. list page change). A refetch over empty data loads via NoData.
if (isFetching && !hasData) {
// Full-panel loader on first fetch or while the fetch is deferred (off-screen); a refetch
// over existing data keeps the renderer mounted, empty data loads via NoData.
if ((isFetching || isVisible === false) && !hasData) {
return <PanelLoader />;
}

View File

@@ -79,6 +79,21 @@ describe('PanelBody', () => {
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('shows the loader while the fetch is deferred (panel not yet scrolled into view)', () => {
render(
<PanelBody
{...baseProps}
panel={runnablePanel()}
data={{} as PanelQueryData}
isFetching={false}
isVisible={false}
/>,
);
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
expect(screen.queryByTestId('mock-renderer')).not.toBeInTheDocument();
});
it('keeps the renderer mounted during a refetch over existing data (e.g. list page change)', () => {
render(
<PanelBody

View File

@@ -113,8 +113,8 @@ function ViewPanelModalHeader({
/>
<Button
size="icon"
variant="solid"
color="primary"
variant="outlined"
color="secondary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"

View File

@@ -22,6 +22,7 @@ function SectionGrid({
sections,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
() =>
items.map((item) => ({

View File

@@ -10,6 +10,10 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
rootMargin: '200px',
};
// Start observing after RGL's mount unfold settles, so a panel that only
// transiently overlaps the viewport during layout doesn't fire a throwaway fetch.
const OBSERVER_START_DELAY_MS = 350;
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -17,9 +21,9 @@ interface SectionGridItemProps {
}
/**
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
* Lazy-loads a single panel: tracks its live viewport intersection and passes it to
* the presentational Panel as `isVisible`, so a board of many panels only fetches
* (and refetches on time change / auto-refresh) what's on screen.
*/
function SectionGridItem({
panel,
@@ -30,7 +34,10 @@ function SectionGridItem({
const isVisible = useIntersectionObserver(
containerRef,
VIEWPORT_OBSERVER_OPTIONS,
true,
// Not once: track the live viewport so a time change / auto-refresh only
// refetches on-screen panels (off-screen ones stay query-disabled).
false,
OBSERVER_START_DELAY_MS,
);
useScrollIntoView(panelId, containerRef);

View File

@@ -23,7 +23,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
block: 'center',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});
@@ -38,7 +38,7 @@ describe('useScrollIntoView', () => {
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
block: 'center',
});
expect(useScrollIntoViewStore.getState().scrollTargetId).toBeNull();
});

View File

@@ -9,7 +9,7 @@ import { useScrollIntoViewStore } from '../../../store/useScrollIntoViewStore';
export function useScrollIntoView(
id: string,
ref: RefObject<HTMLElement>,
block: ScrollLogicalPosition = 'start',
block: ScrollLogicalPosition = 'center',
): void {
const scrollTargetId = useScrollIntoViewStore((s) => s.scrollTargetId);
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);

View File

@@ -1,15 +1,17 @@
import { useMemo } from 'react';
import { SolidInfoCircle } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
// eslint-disable-next-line signoz/no-antd-components -- lightweight description tooltip, matches V1
import { Tooltip } from 'antd';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import CustomSelector from './selectors/CustomSelector';
import DynamicSelector from './selectors/DynamicSelector';
import QuerySelector from './selectors/QuerySelector';
import TextSelector from './selectors/TextSelector';
import VariableValueControl from './selectors/VariableValueControl';
import { useVariableFetchState } from './useVariableFetchState';
import styles from './VariablesBar.module.scss';
import VariableTooltip from './VariableTooltip';
interface VariableSelectorProps {
variable: VariableFormModel;
@@ -32,50 +34,63 @@ function VariableSelector({
onChange,
onAutoSelect,
}: VariableSelectorProps): JSX.Element {
const renderControl = (): JSX.Element => {
switch (variable.type) {
case 'TEXT':
return (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
);
case 'QUERY':
return (
<QuerySelector
variable={variable}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'DYNAMIC':
return (
<DynamicSelector
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
case 'CUSTOM':
default:
return (
<CustomSelector
variable={variable}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
}
};
// Dependency links shown in the hover tooltip: variables this one's query
// references (dependsOn) and query variables that reference this one (usedBy).
const { dependsOn, usedBy } = useMemo(() => {
const references = (text: string | undefined, name: string): boolean =>
!!text && !!name && textContainsVariableReference(text, name);
return {
dependsOn:
variable.type === 'QUERY'
? variables
.filter(
(v) =>
v.name !== variable.name && references(variable.queryValue, v.name),
)
.map((v) => v.name)
: [],
usedBy: variables
.filter(
(v) =>
v.type === 'QUERY' &&
v.name !== variable.name &&
references(v.queryValue, variable.name),
)
.map((v) => v.name),
};
}, [variable, variables]);
const hasTooltip =
!!variable.description || dependsOn.length > 0 || usedBy.length > 0;
// Surface the fetch on the bar itself: a bar flush along the control's bottom
// edge while a QUERY/DYNAMIC variable is loading (or waiting on a parent), so the
// user sees options are being fetched without opening the dropdown.
const { isVariableFetching, isVariableWaiting } = useVariableFetchState(
variable.name,
);
const isFetchingOptions =
(variable.type === 'QUERY' || variable.type === 'DYNAMIC') &&
(isVariableFetching || isVariableWaiting);
const renderControl = (): JSX.Element =>
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>
) : (
<VariableValueControl
variable={variable}
variables={variables}
selections={selections}
selection={selection}
onChange={onChange}
onAutoSelect={onAutoSelect}
/>
);
return (
<div
@@ -84,14 +99,29 @@ function VariableSelector({
>
<Typography.Text className={styles.variableName}>
${variable.name}
{variable.description ? (
<Tooltip title={variable.description}>
{hasTooltip ? (
<Tooltip
title={
<VariableTooltip
description={variable.description}
dependsOn={dependsOn}
usedBy={usedBy}
/>
}
>
<SolidInfoCircle className={styles.infoIcon} size={14} />
</Tooltip>
) : null}
</Typography.Text>
<div className={styles.variableValue}>{renderControl()}</div>
{isFetchingOptions ? (
<span
className={styles.loadingBar}
data-testid={`variable-loading-${variable.name}`}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import cx from 'classnames';
import styles from './VariablesBar.module.scss';
interface VariableTooltipProps {
description?: string;
/** Variables this one references (its query depends on their values). */
dependsOn: string[];
/** Variables whose queries reference this one. */
usedBy: string[];
}
/** Hover-tooltip body for a variable: its description plus its dependencies. */
function VariableTooltip({
description,
dependsOn,
usedBy,
}: VariableTooltipProps): JSX.Element {
const hasDependencies = dependsOn.length > 0 || usedBy.length > 0;
return (
<div className={styles.tooltipContent}>
{description ? (
<div className={styles.tooltipDescription}>{description}</div>
) : null}
{hasDependencies ? (
<>
{description ? <div className={styles.tooltipDivider} /> : null}
{dependsOn.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.dependsColor)}>
Depends on
</div>
<div className={styles.tooltipRefs}>
{dependsOn.map((name) => (
<span
key={name}
className={cx(styles.tooltipRef, styles.dependsColor)}
>
${name}
</span>
))}
</div>
</div>
) : null}
{usedBy.length > 0 ? (
<div className={styles.tooltipSection}>
<div className={cx(styles.tooltipLabel, styles.usedByColor)}>
Used by
</div>
<div className={styles.tooltipRefs}>
{usedBy.map((name) => (
<span key={name} className={cx(styles.tooltipRef, styles.usedByColor)}>
${name}
</span>
))}
</div>
</div>
) : null}
</>
) : null}
</div>
);
}
export default VariableTooltip;

View File

@@ -73,10 +73,57 @@
}
.variableItem {
position: relative;
display: flex;
align-items: center;
}
// Loading indicator: an indeterminate bar flush along the control's bottom edge,
// full width and overlaying the border so it reads as the input's own edge rather
// than a separate element. Non-interactive so the name/description stays hoverable.
.loadingBar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
overflow: hidden;
border-radius: 0 0 2px 2px;
background: color-mix(in srgb, var(--bg-robin-500) 20%, transparent);
pointer-events: none;
}
.loadingBar::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 40%;
border-radius: 2px;
background: var(--bg-robin-500);
animation: variable-loading-slide 1.1s ease-in-out infinite;
}
@keyframes variable-loading-slide {
0% {
left: -40%;
}
100% {
left: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.loadingBar::after {
left: 0;
width: 100%;
animation: none;
opacity: 0.7;
}
}
.variableName {
display: flex;
min-width: 56px;
@@ -87,7 +134,7 @@
border: 1px solid var(--l3-border);
border-radius: 2px 0 0 2px;
background: var(--l3-background);
color: var(--bg-robin-300);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 12px;
font-weight: 400;
@@ -97,11 +144,63 @@
.infoIcon {
display: inline-flex;
margin-left: 2px;
margin-left: 6px;
color: var(--l2-foreground);
vertical-align: middle;
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 240px;
}
.tooltipDescription {
font-size: 12px;
line-height: 1.5;
}
// Divider and labels use the tooltip's own text color at reduced opacity so they
// read on the tooltip surface in either theme without hard-coding a palette.
.tooltipDivider {
height: 1px;
background: currentColor;
opacity: 0.16;
}
.tooltipSection {
display: flex;
flex-direction: column;
gap: 4px;
}
.tooltipLabel {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tooltipRefs {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tooltipRef {
font-size: 12px;
}
// Directional colors: parents (Depends on) in forest, children (Used by) in amber.
.dependsColor {
color: var(--bg-forest-500);
}
.usedByColor {
color: var(--bg-amber-500);
}
.variableValue {
display: flex;
min-width: 120px;

View File

@@ -0,0 +1,153 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import {
configuredDefaultValue,
reconcileWithOptions,
resolveDefaultSelection,
} from '../resolveVariableSelection';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
describe('resolveDefaultSelection', () => {
it('TEXT: uses defaultValue, then textValue, else empty string', () => {
expect(
resolveDefaultSelection(model({ type: 'TEXT', defaultValue: 'd' })),
).toStrictEqual({ value: 'd', allSelected: false });
expect(
resolveDefaultSelection(model({ type: 'TEXT', textValue: 't' })),
).toStrictEqual({ value: 't', allSelected: false });
expect(resolveDefaultSelection(model({ type: 'TEXT' }))).toStrictEqual({
value: '',
allSelected: false,
});
});
it('list: ALL when allowAll (multi + showAllOption) and no default', () => {
expect(
resolveDefaultSelection(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: ALL sentinel default → ALL', () => {
expect(
resolveDefaultSelection(
model({ type: 'CUSTOM', multiSelect: true, defaultValue: '__ALL__' }),
),
).toStrictEqual({ value: null, allSelected: true });
});
it('list: configured default wins over ALL default', () => {
expect(
resolveDefaultSelection(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'x',
}),
),
).toStrictEqual({ value: ['x'], allSelected: false });
});
it('list: no default and no allowAll → empty placeholder (filled after fetch)', () => {
expect(resolveDefaultSelection(model({ type: 'QUERY' }))).toStrictEqual({
value: '',
allSelected: false,
});
expect(
resolveDefaultSelection(model({ type: 'QUERY', multiSelect: true })),
).toStrictEqual({ value: [], allSelected: false });
});
});
describe('reconcileWithOptions', () => {
it('leaves a valid single selection untouched (local-first)', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: 'b', allSelected: false },
['a', 'b'],
),
).toBeNull();
});
it('materializes query ALL to the full option array', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('does not materialize dynamic ALL (sends __all__)', () => {
expect(
reconcileWithOptions(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
{ value: null, allSelected: true },
['a', 'b'],
),
).toBeNull();
});
it('keeps the still-valid subset when options re-scope', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true }),
{ value: ['a', 'b', 'c'], allSelected: false },
['a', 'b', 'd'],
),
).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('falls back to the configured default (else first) when invalid', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', defaultValue: 'b' }),
{ value: '', allSelected: false },
['a', 'b', 'c'],
),
).toStrictEqual({ value: 'b', allSelected: false });
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
['a', 'b'],
),
).toStrictEqual({ value: 'a', allSelected: false });
});
it('does nothing while options are empty', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY' }),
{ value: '', allSelected: false },
[],
),
).toBeNull();
});
});
describe('configuredDefaultValue', () => {
it('TEXT: textValue fallback; list: defaultValue only (no ALL synthesis)', () => {
expect(configuredDefaultValue(model({ type: 'TEXT', textValue: 't' }))).toBe(
't',
);
expect(
configuredDefaultValue(model({ type: 'QUERY', defaultValue: 'x' })),
).toBe('x');
// ALL-by-default list variable is not expanded here (options unknown).
expect(
configuredDefaultValue(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
),
).toBeUndefined();
});
});

View File

@@ -1,31 +0,0 @@
import { withVariablesSearch } from '../variablesUrlState';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
}));
describe('withVariablesSearch', () => {
const current = `?compositeQuery=abc&variables=${encodeURIComponent(
'{"env":"prod"}',
)}`;
it('returns the base unchanged when the current search has no variables', () => {
expect(withVariablesSearch('', '?compositeQuery=abc')).toBe('');
expect(withVariablesSearch('?panelKind=signoz/TablePanel', '')).toBe(
'?panelKind=signoz/TablePanel',
);
});
it('carries only the variables param onto an empty base', () => {
const result = withVariablesSearch('', current);
expect(new URLSearchParams(result).get('variables')).toBe('{"env":"prod"}');
expect(new URLSearchParams(result).get('compositeQuery')).toBeNull();
});
it('appends the variables param to existing base params', () => {
const result = withVariablesSearch('?panelKind=signoz/TablePanel', current);
const params = new URLSearchParams(result);
expect(params.get('panelKind')).toBe('signoz/TablePanel');
expect(params.get('variables')).toBe('{"env":"prod"}');
});
});

View File

@@ -0,0 +1,183 @@
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
import { ALL_SELECTED } from './variablesUrlState';
/**
* Single source of truth for "what value does this variable hold?", shared by the
* three surfaces that used to each own a divergent copy of the rule:
* - {@link resolveDefaultSelection} — the seed-time default (no options yet).
* - {@link reconcileWithOptions} — the post-fetch reconcile (options known).
* - {@link configuredDefaultValue} — the payload fallback when nothing is picked.
*
* Keeping them here means the variable bar, the fetch gate and the panel-query
* payload can never disagree about a variable's default (the previous split
* produced "bar shows ALL while the query omits the variable").
*/
/** An "every option selected" (ALL) selection. */
const ALL_SELECTION: VariableSelection = { value: null, allSelected: true };
/** The `defaultValue` reduced to a single string, or undefined when unset. */
function firstConfiguredDefault(model: VariableFormModel): string | undefined {
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? String(def[0]) : undefined;
}
if (typeof def === 'string' && def !== '') {
return def;
}
return undefined;
}
/** Whether the configured default marks the ALL sentinel. */
function isAllDefault(def: VariableFormModel['defaultValue']): boolean {
return (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
);
}
function isValidSingle(
value: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(value) &&
value !== '' &&
value !== null &&
value !== undefined &&
options.includes(String(value))
);
}
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/**
* For an ALL selection, the value to materialize (or null when unchanged).
* Dynamic ALL travels as the `__all__` wire sentinel and renders ALL from the
* flag, so it needs no materialized value. Query/custom ALL must carry the full
* option array (the payload builder cannot expand it) — keep it in sync.
*/
function materializeAll(
model: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
if (!model.multiSelect || model.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
/**
* The seed-time default for a variable, before any options are fetched.
* - TEXT: the configured default (`defaultValue` → `textValue`), else empty.
* - CUSTOM/QUERY/DYNAMIC: the configured default; else ALL when allowAll is on;
* else a placeholder that {@link reconcileWithOptions} fills with the first
* option once the options resolve.
*/
export function resolveDefaultSelection(
model: VariableFormModel,
): VariableSelection {
if (model.type === 'TEXT') {
return {
value: firstConfiguredDefault(model) ?? model.textValue ?? '',
allSelected: false,
};
}
const def = model.defaultValue;
if (isAllDefault(def)) {
return ALL_SELECTION;
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return ALL_SELECTION;
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
/**
* Reconciles a variable's current selection against its freshly-fetched options.
* Returns the next selection, or null when nothing should change (a valid pick is
* left untouched — local-first). Behaviour, in order:
* - materialize ALL to the full option set (query/custom);
* - keep a still-valid multi-select subset, dropping only invalid entries;
* - otherwise auto-pick the default (or first option) so dependent variables and
* panels always resolve against a usable value.
*/
export function reconcileWithOptions(
model: VariableFormModel,
current: VariableSelection,
options: string[],
): VariableSelection | null {
if (options.length === 0) {
return null;
}
if (current.allSelected) {
return materializeAll(model, options, current.value);
}
if (
model.multiSelect &&
Array.isArray(current.value) &&
current.value.length > 0
) {
const valid = current.value.map(String).filter((c) => options.includes(c));
if (valid.length === current.value.length) {
return null;
}
return valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(model, options);
}
if (!model.multiSelect && isValidSingle(current.value, options)) {
return null;
}
return fillDefault(model, options);
}
/**
* The value to send for a variable when the user has made no selection yet
* (the payload fallback). Mirrors the configured default only — an ALL-by-default
* list variable resolves to `undefined` here (its concrete values are carried by
* the materialized selection once options are known), so it is omitted until then
* rather than sent wrong.
*/
export function configuredDefaultValue(
model: VariableFormModel,
): SelectedVariableValue | undefined {
if (model.type === 'TEXT') {
return firstConfiguredDefault(model) ?? model.textValue ?? undefined;
}
const def = model.defaultValue;
if (Array.isArray(def)) {
return def.length > 0 ? def : undefined;
}
return def || undefined;
}

View File

@@ -1,3 +1,4 @@
import type { VariableType } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
@@ -19,6 +20,32 @@ export function isResolved(selection?: VariableSelection): boolean {
return value !== '' && value !== null && value !== undefined;
}
/**
* Whether a selection carries a value usable when scheduling a dependent
* variable/panel fetch. Unlike {@link isResolved}, a QUERY/CUSTOM ALL counts only
* once materialized into the concrete array (an unmaterialized ALL isn't usable),
* while a DYNAMIC ALL is usable immediately via the `__all__` sentinel.
*/
export function hasUsableValue(
selection: VariableSelection | undefined,
type: VariableType | undefined,
): boolean {
if (!selection) {
return false;
}
if (selection.allSelected) {
if (type === 'DYNAMIC') {
return true;
}
return Array.isArray(selection.value) && selection.value.length > 0;
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== '' && value !== null && value !== undefined;
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,

View File

@@ -1,50 +0,0 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
interface CustomSelectorProps {
variable: VariableFormModel;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Custom-variable options come from the comma-separated `customValue` (no fetch),
* but still auto-select a default/first option so the variable is never left blank.
*/
function CustomSelector({
variable,
selection,
onChange,
onAutoSelect,
}: CustomSelectorProps): JSX.Element {
const options = useMemo(
() =>
sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String),
[variable.customValue, variable.sort],
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default CustomSelector;

View File

@@ -1,140 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface DynamicSelectorProps {
variable: VariableFormModel;
/** All variables + current selections, to scope options by sibling dynamics. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Dynamic-variable options sourced from live telemetry field values for the
* chosen signal + attribute, scoped by the other dynamic variables' selections
* (so e.g. `pod` narrows to the chosen `namespace`). WHEN to fetch is owned by
* the runtime fetch engine: dynamics fetch together once the query variables have
* values, and refetch (via a `cycleId` bump) whenever any variable value changes.
*/
function DynamicSelector({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
existingQuery,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
const payload = data?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default DynamicSelector;

View File

@@ -1,127 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useAutoSelect } from '../useAutoSelect';
import { useVariableFetchState } from '../useVariableFetchState';
import ValueSelector from './ValueSelector';
interface QuerySelectorProps {
variable: VariableFormModel;
/** All current selections, fed to the query as `{ name: value }`. */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* Query-driven options. WHEN to fetch is owned by the runtime fetch engine
* (`variableFetchSlice`): the query is `enabled` while this variable is fetching
* (or settled-after-a-first-fetch, so a cycle bump re-runs it), and the engine's
* per-variable `cycleId` keys the request — so a parent's value change refetches
* only the dependent variables, in dependency order. The current selections feed
* the request payload but are deliberately NOT in the key (V1 parity).
*/
function QuerySelector({
variable,
selections,
selection,
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const { data, isFetching, error, refetch } = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
minTime,
maxTime,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; 0 under auto-refresh so entries don't pile up (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const options = useMemo(() => {
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [data, variable.sort]);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={isFetching || isVariableWaiting}
errorMessage={error ? (error as Error).message || null : null}
onRetry={(): void => {
void refetch();
}}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default QuerySelector;

View File

@@ -0,0 +1,59 @@
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { useAutoSelect } from '../useAutoSelect';
import ValueSelector from './ValueSelector';
import { useVariableOptions } from './useVariableOptions';
interface VariableValueControlProps {
variable: VariableFormModel;
/** All variables (Dynamic scopes its options by sibling selections). */
variables: VariableFormModel[];
/** All current selections (fed to the Query request payload). */
selections: VariableSelectionMap;
selection: VariableSelection;
onChange: (selection: VariableSelection) => void;
/** Batched auto-selection fill applied when options resolve. */
onAutoSelect: (selection: VariableSelection) => void;
}
/**
* The single value picker for QUERY / CUSTOM / DYNAMIC variables. Options + fetch
* state come from {@link useVariableOptions}; this component only reconciles the
* selection against the options and renders — the view is decoupled from how the
* options are sourced (Container/Presentational).
*/
function VariableValueControl({
variable,
variables,
selections,
selection,
onChange,
onAutoSelect,
}: VariableValueControlProps): JSX.Element {
const { options, loading, errorMessage, onRetry } = useVariableOptions(
variable,
variables,
selections,
);
useAutoSelect(variable, options, selection, onAutoSelect);
return (
<ValueSelector
options={options}
multiSelect={variable.multiSelect}
showAllOption={variable.showAllOption}
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
selection={selection}
onChange={onChange}
testId={`variable-select-${variable.name}`}
/>
);
}
export default VariableValueControl;

View File

@@ -0,0 +1,213 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
signalForApi,
sortValuesByOrder,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../dynamicFilter';
import type { VariableSelectionMap } from '../selectionTypes';
import { selectionToPayload } from '../selectionUtils';
import { useVariableFetchState } from '../useVariableFetchState';
export interface VariableOptions {
options: string[];
loading: boolean;
errorMessage: string | null;
onRetry?: () => void;
}
/**
* The option list for a list variable (QUERY / CUSTOM / DYNAMIC), plus its loading
* and error state — the single place the three list types get their options.
* QUERY/DYNAMIC fetch via react-query (WHEN owned by the fetch engine: `enabled`
* gated on the variable's fetch state, keyed by `cycleId`, never by the current
* selections or time — those feed the fetchers (which read the current time at
* call), so the debounced fetch cycle drives refetches). CUSTOM is parsed
* synchronously from its comma list. TEXT never reaches here (it has no options).
*/
export function useVariableOptions(
variable: VariableFormModel,
variables: VariableFormModel[],
selections: VariableSelectionMap,
): VariableOptions {
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Bound cache churn: 0 under auto-refresh so entries don't pile up (V1 parity).
const cacheTime = isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED;
const {
variableFetchCycleId,
isVariableFetching,
isVariableSettled,
isVariableWaiting,
hasVariableFetchedOnce,
} = useVariableFetchState(variable.name);
const onVariableFetchComplete = useDashboardStore(
(s) => s.onVariableFetchComplete,
);
const onVariableFetchFailure = useDashboardStore(
(s) => s.onVariableFetchFailure,
);
const setVariableResolvedEmpty = useDashboardStore(
(s) => s.setVariableResolvedEmpty,
);
// Fetch while this variable is actively fetching, or once settled after a first
// fetch (so a `cycleId` bump re-runs it). Combined with a per-type guard below.
const canFetch =
isVariableFetching || (isVariableSettled && hasVariableFetchedOnce);
// QUERY — options from the test-run endpoint; selections feed the payload, not the key.
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const queryResult = useQuery(
[
'dashboard-variable',
variable.name,
variable.queryValue,
variableFetchCycleId,
],
() =>
dashboardVariablesQuery({
query: variable.queryValue,
variables: payload,
}),
{
enabled: variable.type === 'QUERY' && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
// DYNAMIC — telemetry field values scoped by sibling dynamics via `existingQuery`
// (fed to the fetcher only, not the key — see DynamicSelector history).
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
[variables, selections, variable.name],
);
const dynamicResult = useQuery(
[
'dashboard-variable-dynamic',
variable.name,
variable.dynamicSignal,
variable.dynamicAttribute,
variableFetchCycleId,
],
() =>
getFieldValues(
signalForApi(variable.dynamicSignal),
variable.dynamicAttribute,
undefined,
minTime,
maxTime,
existingQuery || undefined,
),
{
enabled:
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
refetchOnWindowFocus: false,
cacheTime,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
: onVariableFetchComplete(variable.name),
},
);
const queryOptions = useMemo(() => {
const data = queryResult.data;
if (!data || data.statusCode !== 200 || !data.payload) {
return [] as string[];
}
return sortValuesByOrder(
data.payload.variableValues ?? [],
variable.sort,
).map(String);
}, [queryResult.data, variable.sort]);
const dynamicOptions = useMemo(() => {
const data = dynamicResult.data?.data;
const values = data?.normalizedValues ?? data?.values?.StringValues ?? [];
return sortValuesByOrder(values, variable.sort).map(String);
}, [dynamicResult.data, variable.sort]);
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);
// Flag a variable that settled with zero options so dependent panels fall through
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
const effectiveOptions =
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
useEffect(() => {
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
return;
}
setVariableResolvedEmpty(
variable.name,
hasVariableFetchedOnce &&
!isVariableFetching &&
effectiveOptions.length === 0,
);
}, [
variable.type,
variable.name,
hasVariableFetchedOnce,
isVariableFetching,
effectiveOptions.length,
setVariableResolvedEmpty,
]);
if (variable.type === 'CUSTOM') {
return { options: customOptions, loading: false, errorMessage: null };
}
if (variable.type === 'DYNAMIC') {
return {
options: dynamicOptions,
loading: dynamicResult.isFetching || isVariableWaiting,
errorMessage: dynamicResult.error
? (dynamicResult.error as Error).message || null
: null,
onRetry: (): void => {
void dynamicResult.refetch();
},
};
}
return {
options: queryOptions,
loading: queryResult.isFetching || isVariableWaiting,
errorMessage: queryResult.error
? (queryResult.error as Error).message || null
: null,
onRetry: (): void => {
void queryResult.refetch();
},
};
}

View File

@@ -1,61 +1,14 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import type {
SelectedVariableValue,
VariableSelection,
} from './selectionTypes';
/** The variable's default (or first option) as a fresh selection. */
function fillDefault(
variable: VariableFormModel,
options: string[],
): VariableSelection {
const dv = variable.defaultValue;
const fallback = Array.isArray(dv) ? dv[0] : dv;
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: variable.multiSelect ? [initial] : initial,
allSelected: false,
};
}
/** For an all-selected variable, the value to materialize (or null if unchanged). */
function reconcileAllSelected(
variable: VariableFormModel,
options: string[],
current: SelectedVariableValue,
): VariableSelection | null {
// Dynamic ALL travels as the `__all__` wire sentinel and shows ALL from the
// flag, so it needs no materialized value. Query/custom ALL must carry the full
// option array (the payload builder can't expand it) — keep it in sync.
if (!variable.multiSelect || variable.type === 'DYNAMIC') {
return null;
}
const alreadyFull =
Array.isArray(current) &&
current.length === options.length &&
current.every((c) => options.includes(String(c)));
return alreadyFull ? null : { value: options, allSelected: true };
}
function isValidSingle(
current: SelectedVariableValue,
options: string[],
): boolean {
return (
!Array.isArray(current) &&
current !== '' &&
current !== null &&
current !== undefined &&
options.includes(String(current))
);
}
import { reconcileWithOptions } from './resolveVariableSelection';
import type { VariableSelection } from './selectionTypes';
/**
* Reconciles a variable's selection with its freshly-fetched options: materialize
* ALL to the full set, keep a still-valid multi-select subset, else auto-pick the
* default (or first option) so dependent children always have a usable value.
* Reconciles a variable's selection with its freshly-fetched options and fires
* `onAutoSelect` only when the value must change. The reconcile rule lives in
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
* and the panel query can never disagree about a variable's default.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -64,36 +17,10 @@ export function useAutoSelect(
onAutoSelect: (selection: VariableSelection) => void,
): void {
useEffect(() => {
if (options.length === 0) {
return;
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
}
const current = selection.value;
if (selection.allSelected) {
const next = reconcileAllSelected(variable, options, current);
if (next) {
onAutoSelect(next);
}
return;
}
if (variable.multiSelect && Array.isArray(current) && current.length > 0) {
const valid = current.map(String).filter((c) => options.includes(c));
if (valid.length === current.length) {
return;
}
onAutoSelect(
valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(variable, options),
);
return;
}
if (!variable.multiSelect && isValidSingle(current, options)) {
return;
}
onAutoSelect(fillDefault(variable, options));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
}

View File

@@ -6,6 +6,7 @@ import { dtoToFormModel } from '../DashboardSettings/Variables/variableAdapters'
import type { VariableFormModel } from '../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import { resolveDefaultSelection } from './resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -17,26 +18,6 @@ import {
} from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
function defaultSelection(model: VariableFormModel): VariableSelection {
const def = model.defaultValue;
if (
def === ALL_SELECTED ||
(Array.isArray(def) && def.length === 1 && def[0] === ALL_SELECTED)
) {
return { value: null, allSelected: true };
}
if (Array.isArray(def) && def.length > 0) {
return { value: def, allSelected: false };
}
if (typeof def === 'string' && def !== '') {
return { value: model.multiSelect ? [def] : def, allSelected: false };
}
if (model.multiSelect && model.showAllOption) {
return { value: null, allSelected: true };
}
return { value: model.multiSelect ? [] : '', allSelected: false };
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -88,12 +69,20 @@ export function useSeedVariableSelection(
const seeded: VariableSelectionMap = {};
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
if (urlValue !== undefined) {
seeded[variable.name] = fromUrlValue(urlValue, variable);
} else if (selection[variable.name]) {
seeded[variable.name] = selection[variable.name];
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
} else {
seeded[variable.name] = defaultSelection(variable);
seeded[variable.name] = resolveDefaultSelection(variable);
}
});
setVariableValues(dashboardId, seeded);
@@ -116,8 +105,10 @@ export function useSeedVariableSelection(
// eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per dashboard/variable set; the URL is read as of that moment
}, [dashboardId, variables]);
// Always init the context (even with no variables) so panels can tell "ready, none"
// from "not ready yet"; also clears it when the last variable is removed.
useEffect(() => {
if (!dashboardId || variables.length === 0) {
if (!dashboardId) {
return;
}
const names = variables

View File

@@ -11,9 +11,14 @@ import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import type { VariableSelection, VariableSelectionMap } from './selectionTypes';
import { useSeedVariableSelection } from './useSeedVariableSelection';
import { doAllQueryVariablesHaveValues } from './variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from './variablesUrlState';
/**
* Debounce for the fetch cycle, so the on-load time-range settle (default → saved)
* and rapid time-picker changes collapse into one cycle instead of double-fetching.
*/
const FETCH_CYCLE_DEBOUNCE_MS = 250;
interface UseVariableSelection {
variables: VariableFormModel[];
selection: VariableSelectionMap;
@@ -48,9 +53,10 @@ export function useVariableSelection(
(s) => s.enqueueDescendantsBatch,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
// Latest selection, read by the fetch-cycle effect without subscribing to it
// (so a value change doesn't re-trigger a full fetch cycle).
@@ -62,20 +68,39 @@ export function useVariableSelection(
variablesUrlParser.withOptions({ history: 'replace' }),
);
// Start a full fetch cycle on load / dependency-order / time change. A value
// change instead goes through `enqueueDescendants`, not this effect.
// Start a full fetch cycle on load / dependency-order / time change, debounced so
// the initial time-window settle (and rapid time changes) collapse into ONE cycle
// instead of double-fetching every variable. Variables stay disabled until the
// cycle runs, so the transient window is never fetched. A value change instead
// goes through `enqueueDescendants` — immediate, not this effect.
const orderKey = `${fetchContext.queryVariableOrder.join(
',',
)}|${fetchContext.dynamicVariableOrder.join(',')}`;
// Key on the time *selection*, not raw min/max: a relative range recomputes those
// as `now` drifts, which shouldn't refetch. The fetchers still read current time.
const timeKey =
selectedTime === 'custom' ? `custom:${minTime}-${maxTime}` : selectedTime;
// A re-mount re-runs this effect with the same key, which enqueueFetchAll skips.
const fetchCycleKey = `${dashboardId}|${orderKey}|${timeKey}`;
const fetchCycleTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!dashboardId || variables.length === 0) {
return;
return undefined;
}
enqueueFetchAll(
doAllQueryVariablesHaveValues(variables, selectionRef.current),
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
fetchCycleTimer.current = setTimeout(
() => enqueueFetchAll(fetchCycleKey),
FETCH_CYCLE_DEBOUNCE_MS,
);
return (): void => {
if (fetchCycleTimer.current) {
clearTimeout(fetchCycleTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardId, orderKey, minTime, maxTime]);
}, [dashboardId, fetchCycleKey]);
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {

View File

@@ -4,8 +4,6 @@ import type {
VariableFormModel,
VariableType,
} from '../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from './selectionTypes';
import { isResolved } from './selectionUtils';
/**
* Inter-variable dependency graph for runtime selection. A QUERY variable
@@ -242,17 +240,3 @@ export function deriveFetchContext(
dynamicVariableOrder,
};
}
/**
* Whether every QUERY variable already has a usable selection — decides at load
* time whether dynamic variables may fetch immediately or must wait for the
* query variables to settle first (V1 parity).
*/
export function doAllQueryVariablesHaveValues(
variables: VariableFormModel[],
selection: VariableSelectionMap,
): boolean {
return variables
.filter((v) => v.type === 'QUERY')
.every((v) => isResolved(selection[v.name]));
}

View File

@@ -1,4 +1,3 @@
import { QueryParams } from 'constants/query';
import { parseAsJson } from 'nuqs';
import type { SelectedVariableValue } from './selectionTypes';
@@ -14,21 +13,3 @@ export const variablesUrlParser = parseAsJson<
? (v as Record<string, SelectedVariableValue>)
: null,
);
/**
* Extends a search string with the current `?variables=` param (unchanged when
* absent), so the dashboard ↔ editor handoff keeps the selection in the URL and
* it survives a refresh (V1 parity).
*/
export function withVariablesSearch(
base: string,
currentSearch: string,
): string {
const value = new URLSearchParams(currentSearch).get(QueryParams.variables);
if (!value) {
return base;
}
const params = new URLSearchParams(base);
params.set(QueryParams.variables, value);
return `?${params.toString()}`;
}

View File

@@ -1,12 +1,11 @@
import { useCallback, useState } from 'react';
import { generatePath, useLocation } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
import type { PanelKind } from '../Panels/types/panelKind';
import { useDashboardStore } from '../store/useDashboardStore';
import { withVariablesSearch } from '../VariablesBar/variablesUrlState';
interface UseCreatePanelResult {
isPickerOpen: boolean;
@@ -26,7 +25,6 @@ interface UseCreatePanelResult {
*/
export function useCreatePanel(): UseCreatePanelResult {
const { safeNavigate } = useSafeNavigate();
const { search } = useLocation();
const dashboardId = useDashboardStore((s) => s.dashboardId);
const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -50,11 +48,10 @@ export function useCreatePanel(): UseCreatePanelResult {
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
safeNavigate(
`${path}${withVariablesSearch(newPanelSearch(panelKind, target), search)}`,
);
// Variable selection is read from the persisted store, not the URL.
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
},
[safeNavigate, dashboardId, layoutIndex, search],
[safeNavigate, dashboardId, layoutIndex],
);
return {

View File

@@ -57,5 +57,8 @@ export function useGetQueryRangeV5({
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
// A resolved window is immutable per key, so a panel scrolled back into view
// serves cache instead of refetching; a key change or manual refetch still runs.
staleTime: Infinity,
});
}

View File

@@ -1,23 +1,51 @@
import { isResolved } from '../VariablesBar/selectionUtils';
import { hasUsableValue } from '../VariablesBar/selectionUtils';
import { VariableFetchState } from '../store/slices/variableFetchSlice';
import { selectVariableValues } from '../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* True while a panel should stay in its loading state because a variable it
* references is still loading/waiting and has no usable value yet — i.e. the
* first load. Once the variable has a value, a later change no longer blocks the
* panel (it refetches over stale data instead). V1 parity with
* `useIsPanelWaitingOnVariable`.
* Whether a panel should stay loading because a QUERY/DYNAMIC variable it references
* isn't ready to substitute. A concrete pick (not ALL) and a DYNAMIC ALL are ready
* immediately; an unselected value or a QUERY/CUSTOM ALL waits while it's still
* resolving, then until it settles with a value — so a panel on a chain holds until
* the last variable it depends on resolves. A fetch error or a settled-empty variable
* releases it (no value is coming — render rather than hang).
*/
export function useIsPanelWaitingOnVariable(names: string[]): boolean {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const states = useDashboardStore((s) => s.variableFetchStates);
const variableTypes = useDashboardStore(
(s) => s.variableFetchContext?.variableTypes,
);
const fetchStates = useDashboardStore((s) => s.variableFetchStates);
const resolvedEmpty = useDashboardStore((s) => s.variableResolvedEmpty);
const selection = useDashboardStore(selectVariableValues(dashboardId));
return names.some((name) => {
const state = states[name];
const inFlight =
state === 'loading' || state === 'revalidating' || state === 'waiting';
return isResolved(selection[name]) ? false : inFlight;
const type = variableTypes?.[name];
if (type !== 'QUERY' && type !== 'DYNAMIC') {
return false;
}
const value = selection[name];
// A concrete pick is authoritative; a DYNAMIC ALL is the stable `__all__`
// sentinel — both ready without waiting.
if (value && !value.allSelected && hasUsableValue(value, type)) {
return false;
}
if (type === 'DYNAMIC' && value?.allSelected) {
return false;
}
// Unselected, or a QUERY/CUSTOM ALL whose array the fetch produces: wait while
// resolving, then until it settles with a usable value.
const state = fetchStates[name];
if (
state === VariableFetchState.Waiting ||
state === VariableFetchState.Loading
) {
return true;
}
if (hasUsableValue(value, type)) {
return false;
}
return state !== VariableFetchState.Error && !resolvedEmpty[name];
});
}

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