Compare commits

..

17 Commits

Author SHA1 Message Date
vikrantgupta25
422c9588c4 feat(authz): gate v5 query_range on service.name telemetry selectors 2026-07-13 18:31:19 +05:30
vikrantgupta25
5c6e8b16f9 Merge remote-tracking branch 'origin/main' into platform-pod/issues/2682
# Conflicts:
#	pkg/signoz/provider.go
2026-07-13 17:53:10 +05:30
vikrantgupta25
168658b4c3 Merge remote-tracking branch 'origin/platform-pod/issues/2606' into platform-pod/issues/2682 2026-07-13 17:50:09 +05:30
Vinicius Lourenço
6975c4d90c chore(codeowners): update infra monitoring file owner (#12073)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
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
Swapnil Nakade
86313ae561 feat: clousql - adding metrics in definition and dashboard JSON (#12072)
* feat: adding metrics in definition and dashboard JSON

* refactor: renaming cloudsql to cloudsql_postgres

* refactor: generating openapi specs
2026-07-13 09:57:49 +00:00
Gaurav Tewari
f764a8d9af feat(llm-attribute-mapping): read-only listing [2/5] (#11779)
* 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

* feat(llm-attribute-mapping): add attribute mapping foundation (route, permission, page shell)

* fix: css styling

* refactor: css module

* feat(llm-attribute-mapping): read-only listing on CSS modules [2/5]

Rebase the listing slice onto the foundation's CSS-module refactor
(which deleted the global stylesheet) and migrate it accordingly:

- Merge listing styles into LLMObservabilityAttributeMapping.module.scss
  (groups/mappers tables, source chips, index badge, error/footer).
- Convert all listing components from global BEM classNames to
  styles.* module access; drop dead/style-less classes (am-table,
  am-row-actions, am-add-row, *_edited, mappers-table__error).
- Adopt theme-aware semantic tokens (--l2/l3-*, --accent-primary,
  --callout-error-*) in place of --bg-* primitives.

* chore: migrate tanstack table

* chore: remove comment

* fix: disable isDirty in case of llm pricing

* refactor: number

* feat: add search , dropdown and flag

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

* fix: add isFetchingFeatureFlags

* chore: update flag

* refactor: shell

* fix: add key to route

* feat: add flags

* chore: additional refactor

* chore: add commet in utis

* chore: self review changes

* refactor: types and other things

* refactor: types and other things

* chore: add disable on source id

* empty commit

* chore: empty commit

* fix: add demo side nav on sidenav

* chore: remove demo side nav

* refactor: update routes

* chore: remove usd selector for now

* fix: layout shift

* refactor: styles

* refactor: typography component

* refactor: more changes

* refactor: typograhy

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

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

* refactor: more changes

* refactor: styling and components

* refactor: styling and components

* chore: add a tooltip on hover

* feat: add delete confirm modal

* fix: update title

* refactor: css variables

* refactor: use signoz button and minor css update

* chore: sync table

* chore: remove extra comment

* chore: use typograpgy test in table config

* fix: minior issues

* fix: llm pricing listing

* refactor: remove extra classes

* refactor: side nav changes

* fix: update missing styles

* chore: update edit and delete options

* chore: remove extra comment

* chore: revert env changes

* chore: add enable check

* chore: remove divider

* refactor: use delete confirm dialog

* chore: remove scss file

* 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

* chore: remove worktree

* chore: revert env.ts

* chore: add attribute mapping foundation

* chore: update ui and add animation

* refactor: components update

* chore: typography changes

* chore: typography changes

* chore: use badge

* refactor: basic components

* chore: remove hardcoded value

* chore: add comments & tests

* chore: update env.ts

* chore: update tests

* chore: self review changes

* chore: update test cases

* chore: remove extra comments

* refactor(llm-pricing): share toast copy via constants

* chore: use constants

* chore: redclared constants

* chore: update test cases

* chore: remove unused component

* chore: update types

* chore: update ui

* refactor: minor things

* chore: break down thingsinto comps

* chore: update files

* fix: update mapping

* chore: remove draft logic no need for now

* chore: more refactor

* chore: remove comments

* chore: refactor route

* chore: update query refech on mount

* chore: update skeleton

* refactor: code

* refactor: code

* refactor: eslint disable

* chore: update selector

* chore: sort

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-13 09:48:51 +00:00
Abhi kumar
3e971a902a fix(dashboard-v2): carry unsaved panel edits into view mode (#12049)
* fix(dashboard-v2): carry unsaved panel edits into view mode

Switching from the panel editor to View Mode dropped un-saved config edits
(thresholds, units, columns, legend, formatting, axes) — the View modal
re-seeded from the saved panel spec, carrying only the live query.

Hand the live draft spec off via a tab-scoped sessionStorage handoff,
correlated to the panel by dashboardId + panelId. The query stays in the URL
(compositeQuery) so the query builder hydrates; the rest of the spec rides in
sessionStorage so the edits survive a refresh without bloating the URL. The
handoff is cleared on plain View, grid drilldown, and close so it can't seed
a stale view.

* fix(dashboard-v2): hide 'Switch to Edit Mode' without edit permission

The View panel modal is reachable by read-only users, so gate the
Switch to Edit Mode button on canEditDashboard && !isLocked, mirroring
how the panel actions menu gates the Edit panel item.

* feat(dashboard-v2): track panel view/edit mode switch events

Add DashboardEvents enum and log SWITCH_TO_EDIT_MODE (View modal) and
SWITCH_TO_VIEW_MODE (panel editor) when the user toggles between the
two panel modes.

* fix(dashboard-v2): bound query cacheTime to 0 under auto-refresh

Under auto-refresh each cycle mints a fresh time-keyed query, so unused
entries accumulate and can OOM the tab. Drop cacheTime to 0 when
auto-refresh is enabled (V1 parity) for panel queries and the query/
dynamic variable selectors; keep DASHBOARD_CACHE_TIME otherwise.
2026-07-13 09:27:40 +00:00
Srikanth Chekuri
e0a0f49fb4 fix(alerts): surface individual validation errors in API response (#11756)
* fix(alerts): surface individual validation errors in API response

* chore: address lint
2026-07-13 09:23:28 +00:00
Shivam Gupta
03abb3ca90 fix: update stale docs links in backend and remaining frontend (#12096)
Repairs 10 broken signoz.io/docs links (5 hard 404s + 5 dead anchors)
that survived the frontend-only sweep in #11319 because they live in
the Go backend and two frontend files it did not cover.

- infra-monitoring readiness checks: drop the removed `user-guides/`
  path segment and remap to the current hostmetrics/k8s-metrics anchors
- querybuilder / telemetrylogs search-troubleshooting errors: point to
  the reworded Q&A anchors (update matching test assertion)
- alert generatorURL fallback: `alerts-management/#generator-url` ->
  `alerts/` (anchor removed in docs restructure)
- missing-spans banner: -> traces-management troubleshooting FAQ anchor
- agent-skills install link: `#installation` -> `#install-the-plugin`

Every changed URL verified live (200 + anchor present).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:49:31 +00:00
Aditya Singh
e4f9daf7d2 feat(qb): add time_series export support + data-export foundation for other format supports (#12025)
* feat: rename existing export logic to follow the new export data structure

* feat(data-export): add time_series serializer

Pure serializer that walks the V5 time_series tree (results → aggregations → series) into a format-agnostic SerializedTable — tidy layout, one row per (series, timestamp), labels as columns, raw values, y-axis unit in the value header. Series names match the chart legend (getLabelName + getLegend resolve legend templates and aggregation aliases/expressions from the builder query).

* feat(data-export): add csv/jsonl formatters and timestamped client download

toCsv/toJsonl turn a SerializedTable into CSV or newline-delimited JSON; downloadFile triggers a client-side blob download with a timestamped filename (base-YYYY-MM-DD_HH-mm-ss.ext) so repeated exports never collide and record when they were taken.

* feat(data-export): add useClientExport dispatch hook

Frontend-driven export hook: narrows a V5 queryRange response by request type, serializes time_series (scalar lands with the next sub-issue), formats as csv/jsonl and downloads. Takes the builder query for chart-parity series naming. Backend-driven export stays in useServerExport.

* test(data-export): assert timestamped filenames via the naming helper

Review feedback: the filename-format regexes duplicated the format spec across tests. The hook tests now freeze the clock and assert delegation to getTimestampedFileName; the format itself stays pinned by the single exact-string test beside the function.

* feat: comment update

* feat: use exsiting request type
2026-07-13 08:05:27 +00:00
vikrantgupta25
9be93a308a refactor(telemetry): restructure normalizer file and quote bare values 2026-07-13 13:32:56 +05:30
Vinicius Lourenço
d1a06a91bf test(service-account): mock motion lib & change drawer open/close detection (#12077)
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
* test(service-account): mock motion lib & change drawer open/close detection

* test(serviceaccount): remove getByRole & use wait for instead of waitForElementToBeRemoved
2026-07-13 06:03:15 +00:00
vikrantgupta25
042e1fb4f0 feat(telemetry): add where clause visitor 2026-07-13 10:12:04 +05:30
vikrantgupta25
1798abfc6b chore(docs): regenerate openapi spec with telemetry read scopes 2026-07-07 14:59:00 +05:30
vikrantgupta25
f155f71051 feat(authz): widen telemetry selector segments to 128 bits
64-bit truncation permits chosen-collision attacks at ~2^32 work; 128 bits
pushes this to 2^64. No hashed selector is persisted yet, so the change is
free.
2026-07-07 14:57:08 +05:30
vikrantgupta25
4559a78752 feat(authz): enable FGA for telemetry resources on v5 query_range
Authorize /api/v5/query_range and /preview at the telemetry-resource level,
derived from the request body:

- coretypes: ResourceWithID + ResourceExtractor as the resource-level analogue
  of the id extractors; NewResolvedResourceWithID/NewResolvedResourceWithError;
  telemetryresource selector regex widened to query-type selectors with up to
  two hashed segments (metric name, where clause) or wildcards
- telemetrytypes: QueryRangeResources maps each query to its telemetry
  resource (signal/source aware: audit-logs, meter-metrics) with a hierarchical
  selector id (query_type/<hash(metric)>/<hash(where)>); PrefixSelector expands
  the id into the grant ladder [exact, prefix/*..., *]
- handler: generic TelemetryResourceDef fans out an injected ResourceExtractor;
  fails closed when extraction errors or resolves nothing
- audit: log and skip resolved resources that carry a resolution error
- querier routes: ViewAccess -> CheckResources with telemetry read scopes;
  substitute_vars stays ViewAccess (no telemetry access)
- sqlmigration 099: backfill telemetry read tuples for existing orgs
  (admin: logs/traces/metrics/audit-logs/meter-metrics; editor/viewer:
  logs/traces/metrics)
2026-07-07 14:27:20 +05:30
158 changed files with 7232 additions and 5408 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

@@ -58,7 +58,6 @@ jobs:
- rootuser
- serviceaccount
- querier_json_body
- promqlparity
- querier_skip_resource_fingerprint
- ttl
sqlstore-provider:

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:
@@ -1494,7 +1494,7 @@ components:
- cosmosdb
- cassandradb
- redis
- cloudsql
- cloudsql_postgres
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -12022,7 +12022,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesGettableRole'
type: array
status:
type: string
@@ -12206,7 +12206,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
$ref: '#/components/schemas/AuthtypesRole'
status:
type: string
required:
@@ -24405,9 +24405,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range
tags:
- querier
@@ -24474,9 +24482,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
- tokenizer:
- VIEWER
- logs:read
- traces:read
- metrics:read
- audit-logs:read
- meter-metrics:read
summary: Query range preview
tags:
- querier

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,86 +200,42 @@ 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)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
if err != nil {
return err
}
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
if err != nil {
return err
}
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
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 +244,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 +260,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 +272,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 +341,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

@@ -0,0 +1,88 @@
// oxlint-disable-next-line no-restricted-imports
import * as React from 'react';
// In jsdom, AnimatePresence from motion/react keeps children in DOM during exit
// animations (awaiting rAF-driven completion that never fully runs in jsdom).
// This mock makes AnimatePresence render children immediately and makes motion.*
// elements render as their plain HTML equivalents without animation side-effects.
//
// IMPORTANT: motion component references are cached so React sees a stable
// component identity across re-renders and does not enter an infinite remount loop.
const MOTION_PROPS_TO_STRIP = new Set([
'initial',
'animate',
'exit',
'variants',
'transition',
'whileHover',
'whileTap',
'whileFocus',
'whileInView',
'layout',
'layoutId',
'onAnimationStart',
'onAnimationComplete',
]);
const cache = new Map<string, React.ComponentType>();
function getMotionComponent(tag: string): React.ComponentType {
if (!cache.has(tag)) {
const Component = React.forwardRef<HTMLElement, Record<string, unknown>>(
(props, ref) => {
const domProps: Record<string, unknown> = {};
for (const [k, v] of Object.entries(props)) {
if (!MOTION_PROPS_TO_STRIP.has(k)) {
domProps[k] = v;
}
}
return React.createElement(tag, { ...domProps, ref });
},
);
Component.displayName = `motion.${tag}`;
cache.set(tag, Component as unknown as React.ComponentType);
}
return cache.get(tag) as React.ComponentType;
}
const motionHandler: ProxyHandler<Record<string, React.ComponentType>> = {
get(_target, prop: string) {
return getMotionComponent(prop);
},
};
export const AnimatePresence: React.FC<{
children?: React.ReactNode;
mode?: string;
}> = ({ children }) => React.createElement(React.Fragment, null, children);
export const motion = new Proxy(
{} as Record<string, React.ComponentType>,
motionHandler,
);
export const useAnimation = (): Record<string, unknown> => ({
start: (): unknown => Promise.resolve(),
stop: (): unknown => undefined,
set: (): unknown => undefined,
});
export const useMotionValue = (
initial: unknown,
): { get: () => unknown; set: () => void } => ({
get: (): unknown => initial,
set: (): unknown => undefined,
});
export const useTransform = (): { get: () => number } => ({
get: (): number => 0,
});
export const useSpring = (v: unknown): unknown => v;
export const useScroll = (): { scrollY: { get: () => number } } => ({
scrollY: { get: (): number => 0 },
});
export default { motion, AnimatePresence };

View File

@@ -20,6 +20,7 @@ const config: Config.InitialOptions = {
'\\.module\\.mjs$': '<rootDir>/__mocks__/cssMock.ts',
'\\.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,

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
@@ -2813,7 +2813,7 @@ export enum CloudintegrationtypesServiceIDDTO {
cosmosdb = 'cosmosdb',
cassandradb = 'cassandradb',
redis = 'redis',
cloudsql = 'cloudsql',
cloudsql_postgres = 'cloudsql_postgres',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -10614,7 +10614,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesRoleDTO[];
data: AuthtypesGettableRoleDTO[];
/**
* @type string
*/
@@ -10636,7 +10636,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleWithTransactionGroupsDTO;
data: AuthtypesRoleDTO;
/**
* @type string
*/

View File

@@ -92,6 +92,7 @@ function CreateServiceAccountModal(): JSX.Element {
width="narrow"
className="create-sa-modal"
disableOutsideClick={isErrorModalVisible}
testId="create-service-account-modal"
>
<div className="create-sa-modal__content">
<form

View File

@@ -1,13 +1,7 @@
import { toast } from '@signozhq/ui/sonner';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
render,
screen,
userEvent,
waitFor,
waitForElementToBeRemoved,
} from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateServiceAccountModal from '../CreateServiceAccountModal';
@@ -89,7 +83,7 @@ describe('CreateServiceAccountModal', () => {
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
screen.queryByTestId('create-service-account-modal'),
).not.toBeInTheDocument();
});
});
@@ -129,7 +123,7 @@ describe('CreateServiceAccountModal', () => {
});
expect(
screen.getByRole('dialog', { name: /New Service Account/i }),
screen.getByTestId('create-service-account-modal'),
).toBeInTheDocument();
});
@@ -137,15 +131,14 @@ describe('CreateServiceAccountModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const dialog = await screen.findByRole('dialog', {
name: /New Service Account/i,
});
await screen.findByTestId('create-service-account-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitForElementToBeRemoved(dialog);
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
await waitFor(() => {
expect(
screen.queryByTestId('create-service-account-modal'),
).not.toBeInTheDocument();
});
});
it('shows "Name is required" after clearing the name field', async () => {

View File

@@ -3,7 +3,7 @@ import { Button, Popover, Tooltip } from 'antd';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { useExportRawData } from 'hooks/useExportData/useServerExport';
import { Download, LoaderCircle } from '@signozhq/icons';
import { DataSource } from 'types/common/queryBuilder';

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

@@ -151,6 +151,7 @@ function AddKeyModal(): JSX.Element {
className="add-key-modal"
showCloseButton
disableOutsideClick={isErrorModalVisible}
testId="add-key-modal"
>
{phase === Phase.FORM && (
<KeyFormPhase

View File

@@ -91,6 +91,7 @@ function DeleteAccountModal(): JSX.Element {
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
data-testid="confirm-delete-btn"
>
<Trash2 size={12} />
Delete
@@ -111,6 +112,7 @@ function DeleteAccountModal(): JSX.Element {
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
testId="delete-service-account-modal"
footer={footer}
>
{content}

View File

@@ -175,6 +175,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
testId="edit-key-modal"
footer={
isRevokeConfirmOpen ? (
<RevokeKeyFooter

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

@@ -2,13 +2,7 @@ import { toast } from '@signozhq/ui/sonner';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
render,
screen,
userEvent,
waitFor,
waitForElementToBeRemoved,
} from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AddKeyModal from '../AddKeyModal';
@@ -97,7 +91,7 @@ describe('AddKeyModal', () => {
await screen.findByText('snz_abc123xyz456secret');
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
expect(screen.getByTestId('add-key-modal')).toBeInTheDocument();
});
it('copy button writes key to clipboard and shows toast.success', async () => {
@@ -133,9 +127,11 @@ describe('AddKeyModal', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderModal();
const dialog = await screen.findByRole('dialog', { name: /Add a New Key/i });
await screen.findByTestId('add-key-modal');
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitForElementToBeRemoved(dialog);
await waitFor(() => {
expect(screen.queryByTestId('add-key-modal')).not.toBeInTheDocument();
});
});
});

View File

@@ -73,9 +73,7 @@ describe('EditKeyModal (URL-controlled)', () => {
it('renders nothing when edit-key param is absent', () => {
renderModal(null, { account: 'sa-1' });
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
});
it('renders key data from prop when edit-key param is set', async () => {
@@ -102,9 +100,7 @@ describe('EditKeyModal (URL-controlled)', () => {
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
});
});
@@ -131,9 +127,7 @@ describe('EditKeyModal (URL-controlled)', () => {
expect(latestUrlUpdate.queryString).not.toContain('edit-key=');
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
});
});
@@ -145,9 +139,7 @@ describe('EditKeyModal (URL-controlled)', () => {
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
// Same dialog, now showing revoke confirmation
await expect(
screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('edit-key-modal')).toBeInTheDocument();
expect(
screen.getByText(/Revoking this key will permanently invalidate it/i),
).toBeInTheDocument();
@@ -170,9 +162,7 @@ describe('EditKeyModal (URL-controlled)', () => {
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
).not.toBeInTheDocument();
expect(screen.queryByTestId('edit-key-modal')).not.toBeInTheDocument();
});
});
});

View File

@@ -222,21 +222,20 @@ describe('ServiceAccountDrawer', () => {
screen.getByRole('button', { name: /Delete Service Account/i }),
);
const dialog = await screen.findByRole('dialog', {
name: /Delete service account CI Bot/i,
});
expect(dialog).toBeInTheDocument();
await screen.findByTestId('delete-service-account-modal');
expect(
screen.getByTestId('delete-service-account-modal'),
).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /^Delete$/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await user.click(screen.getByTestId('confirm-delete-btn'));
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
});
await waitFor(
() => {
expect(deleteSpy).toHaveBeenCalled();
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});
it('deleted account shows read-only name, no Save button, no Delete button', async () => {

View File

@@ -1,3 +1,4 @@
export enum SESSIONSTORAGE {
RETRY_LAZY_REFRESHED = 'retry-lazy-refreshed',
VIEW_PANEL_HANDOFF = 'view-panel-handoff',
}

View File

@@ -0,0 +1,7 @@
.pageError {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,21 @@
import styles from './AttributeMappingsTab.module.scss';
import MappingsTable from './components/MappingsTable/MappingsTable';
import { useAttributeMappingStore } from './hooks/useAttributeMappingStore';
function AttributeMappingsTab(): JSX.Element {
const store = useAttributeMappingStore();
return (
<div data-testid="attribute-mappings-tab">
{store.isError ? (
<div className={styles.pageError} role="alert">
Failed to load mapping groups. Please try again.
</div>
) : (
<MappingsTable store={store} />
)}
</div>
);
}
export default AttributeMappingsTab;

View File

@@ -0,0 +1,267 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
makeMapper,
makeMappersResponse,
mappersEndpoint,
mockGroups,
mockMappers,
} from 'container/LLMObservability/AttributeMapping/__tests__/fixtures';
import AttributeMappingsTab from '../AttributeMappingsTab';
function setupGroups(groups = mockGroups): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(groups))),
),
);
}
function setupMappers(mappers = mockMappers, groupId = 'group-1'): void {
server.use(
rest.get(mappersEndpoint(groupId), (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeMappersResponse(mappers))),
),
);
}
async function expandGroup(
user: ReturnType<typeof userEvent.setup>,
groupId = 'group-1',
): Promise<void> {
await user.click(screen.getByTestId(`group-expand-${groupId}`));
}
describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
window.history.pushState(null, '', '/');
});
afterEach(() => {
server.resetHandlers();
});
it('renders no error banner on a successful load', async () => {
setupGroups();
render(<AttributeMappingsTab />);
await waitFor(() =>
expect(screen.getByTestId('group-name-group-1')).toBeInTheDocument(),
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('shows an error banner when the groups request fails', async () => {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
);
render(<AttributeMappingsTab />);
await expect(screen.findByRole('alert')).resolves.toHaveTextContent(
'Failed to load mapping groups. Please try again.',
);
});
it('shows the empty state when there are no groups', async () => {
setupGroups([]);
render(<AttributeMappingsTab />);
await expect(
screen.findByTestId('mapper-groups-empty'),
).resolves.toHaveTextContent('No mapping groups yet.');
});
it('renders each group header row with its name, condition count and status', async () => {
setupGroups();
render(<AttributeMappingsTab />);
// Condition filters are no longer shown inline as clauses — the header
// carries a count instead (the keys surface in the group drawer, later PR).
// Group headers are antd Collapse panels, so rows scope to the panel item.
// group-1: enabled, with attribute + resource condition keys.
const enabledRow = (await screen.findByTestId('group-name-group-1')).closest(
'.ant-collapse-item',
) as HTMLElement;
expect(
within(enabledRow).getByTestId('group-name-group-1'),
).toHaveTextContent('demo');
expect(
within(enabledRow).getByTestId('group-condition-count-group-1'),
).toHaveTextContent('2 conditions');
expect(within(enabledRow).getByTestId('group-enabled-group-1')).toBeChecked();
// group-2: disabled, with no condition keys.
const disabledRow = screen
.getByTestId('group-name-group-2')
.closest('.ant-collapse-item') as HTMLElement;
expect(within(disabledRow).getByText('Tool')).toBeInTheDocument();
expect(
within(disabledRow).getByTestId('group-condition-count-group-2'),
).toHaveTextContent('0 conditions');
expect(
within(disabledRow).getByTestId('group-enabled-group-2'),
).not.toBeChecked();
});
it('renders the group enable state as a read-only switch', async () => {
setupGroups();
render(<AttributeMappingsTab />);
// The status switch reflects enabled state but is non-interactive in this
// read-only listing — editing lands in a later PR.
const toggle = await screen.findByTestId('group-enabled-group-1');
expect(toggle).toBeChecked();
expect(toggle).toBeDisabled();
});
it("reveals a group's mappers on expand and hides them on collapse", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1' })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// The toggle is the antd Collapse header, which owns the expanded state.
const header = screen
.getByTestId('group-expand-group-1')
.closest('.ant-collapse-header') as HTMLElement;
expect(header).toHaveAttribute('aria-expanded', 'false');
await expandGroup(user);
expect(header).toHaveAttribute('aria-expanded', 'true');
await expect(
screen.findByTestId('mapper-target-mapper-1'),
).resolves.toBeInTheDocument();
await expandGroup(user);
await waitFor(() =>
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument(),
);
});
it("lazily fetches and renders a group's mappers on first expand", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([
makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model', enabled: true }),
]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
// Mappers are not fetched until the row is expanded.
expect(
screen.queryByTestId('mapper-target-mapper-1'),
).not.toBeInTheDocument();
await expandGroup(user);
const target = await screen.findByTestId('mapper-target-mapper-1');
expect(target).toHaveTextContent('gen_ai.request.model');
const mapperRow = target.closest('tr') as HTMLElement;
// Sources ordered by priority, highest first (see fixtures).
const sources = within(mapperRow).getByTestId('mapper-sources-mapper-1');
expect(sources).toHaveTextContent('genai.model');
expect(sources).toHaveTextContent('llm.model');
// Writes-to field context + enabled status (an inline Switch, not text).
expect(within(mapperRow).getByText('attribute')).toBeInTheDocument();
expect(
within(mapperRow).getByTestId('mapper-enabled-mapper-1'),
).toBeChecked();
});
it("renders a mapper's enable state as a read-only switch", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', enabled: true })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
// Like the group switch, a mapper's status switch reflects state without
// accepting flips in this read-only listing.
const toggle = await screen.findByTestId('mapper-enabled-mapper-1');
expect(toggle).toBeChecked();
expect(toggle).toBeDisabled();
});
it('shows the mappers error state when the mappers request fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
server.use(
rest.get(mappersEndpoint('group-1'), (_req, res, ctx) =>
res(ctx.status(500)),
),
);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(
screen.findByTestId('mappers-error-group-1'),
).resolves.toHaveTextContent('Failed to load mappings. Please try again.');
});
it('shows the mappers empty state when a group has no mappers', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(
screen.findByTestId('mappers-empty-group-1'),
).resolves.toHaveTextContent('No mappings in this group yet.');
});
it('collapses extra mapper sources into a "+N more" label beyond the visible cap', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([
makeMapper({
id: 'mapper-1',
config: {
sources: [1, 2, 3, 4, 5].map((priority) => ({
key: `source-${priority}`,
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
})),
},
}),
]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await expect(screen.findByText('+2 more')).resolves.toBeInTheDocument();
});
it('shows a muted placeholder when a mapper has no sources', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', config: { sources: [] } })]);
render(<AttributeMappingsTab />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
await waitFor(() =>
expect(screen.getByTestId('mapper-sources-mapper-1')).toHaveTextContent('—'),
);
});
});

View File

@@ -0,0 +1,19 @@
.groupHeaderLabel {
display: flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
}
.groupName {
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.groupCount {
color: var(--l3-foreground);
font-size: var(--font-size-xs);
white-space: nowrap;
}

View File

@@ -0,0 +1,36 @@
import { Typography } from '@signozhq/ui/typography';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeader.module.scss';
interface GroupHeaderProps {
group: MappingGroup;
}
function GroupHeader({ group }: GroupHeaderProps): JSX.Element {
const conditionCount = group.attributes.length + group.resource.length;
return (
<div
className={styles.groupHeaderLabel}
data-testid={`group-expand-${group.id}`}
>
<Typography.Text
as="span"
className={styles.groupName}
testId={`group-name-${group.id}`}
>
{group.name}
</Typography.Text>
<Typography.Text
as="span"
className={styles.groupCount}
testId={`group-condition-count-${group.id}`}
>
· {conditionCount} {conditionCount === 1 ? 'condition' : 'conditions'}
</Typography.Text>
</div>
);
}
export default GroupHeader;

View File

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

View File

@@ -0,0 +1,6 @@
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
}

View File

@@ -0,0 +1,26 @@
import { Switch } from '@signozhq/ui/switch';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import styles from './GroupHeaderActions.module.scss';
interface GroupHeaderActionsProps {
group: MappingGroup;
}
function GroupHeaderActions({ group }: GroupHeaderActionsProps): JSX.Element {
return (
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<Switch
value={group.enabled}
// We don't yet support toggling a group's enabled state in this read-only PR, so disable the switch. A later PR will add the toggle handler and its drawer.
disabled
testId={`group-enabled-${group.id}`}
/>
</div>
);
}
export default GroupHeaderActions;

View File

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

View File

@@ -0,0 +1,14 @@
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mapperStateRow .stateCell {
color: var(--l3-foreground);
}
.stateCell {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
font-size: var(--periscope-font-size-base);
}

View File

@@ -0,0 +1,98 @@
import { useListSpanMappers } from 'api/generated/services/spanmapper';
import { motion } from 'motion/react';
import {
MappingGroup,
Mapping,
} from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingsFromListResponse } from 'container/LLMObservability/AttributeMapping/utils';
import { COLUMN_COUNT } from '../constants';
import MapperRow, { MapperRowSkeleton } from '../MapperRow';
import MappingsColgroup from '../MappingsColgroup';
import styles from './GroupMappers.module.scss';
const MAPPER_SKELETON_ROWS = 1;
const STATE_ROW_MOTION = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.18, ease: 'easeOut' },
} as const;
interface StateRowProps {
groupId: string;
}
function ErrorRow({ groupId }: StateRowProps): JSX.Element {
return (
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
<td
colSpan={COLUMN_COUNT}
className={styles.stateCell}
data-testid={`mappers-error-${groupId}`}
>
Failed to load mappings. Please try again.
</td>
</motion.tr>
);
}
function EmptyRow({ groupId }: StateRowProps): JSX.Element {
return (
<motion.tr className={styles.mapperStateRow} {...STATE_ROW_MOTION}>
<td
colSpan={COLUMN_COUNT}
className={styles.stateCell}
data-testid={`mappers-empty-${groupId}`}
>
No mappings in this group yet.
</td>
</motion.tr>
);
}
interface GroupMappersProps {
group: MappingGroup;
}
function GroupMappers({ group }: GroupMappersProps): JSX.Element {
const {
data: mappers = [],
isLoading,
isError,
} = useListSpanMappers<Mapping[]>(
{
groupId: group.id,
},
{
query: {
refetchOnMount: false,
select: buildMappingsFromListResponse,
},
},
);
let rows: JSX.Element[];
if (isError) {
rows = [<ErrorRow key="error" groupId={group.id} />];
} else if (isLoading) {
rows = Array.from({ length: MAPPER_SKELETON_ROWS }).map((_, index) => (
<MapperRowSkeleton key={`mapper-skeleton-${index}`} />
));
} else if (mappers.length === 0) {
rows = [<EmptyRow key="empty" groupId={group.id} />];
} else {
rows = mappers.map((mapper, index) => (
<MapperRow key={mapper.id} mapper={mapper} index={index} />
));
}
return (
<table className={styles.table}>
<MappingsColgroup />
<tbody>{rows}</tbody>
</table>
);
}
export default GroupMappers;

View File

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

View File

@@ -0,0 +1,65 @@
.mapperRow {
&:hover {
background: var(--l2-background-hover);
}
}
.cell {
padding: var(--spacing-4) var(--spacing-6);
vertical-align: middle;
color: var(--l1-foreground);
font-size: var(--periscope-font-size-base);
}
// Indent the first cell so mapper rows read as nested under their group.
.targetCell {
padding-left: var(--spacing-12);
}
// Shorter vertical padding so the loading state reads as a compact placeholder.
.skeletonCell {
composes: cell;
:global(.ant-skeleton-input) {
min-height: 18px !important;
height: 18px !important;
}
:global(.ant-skeleton-button) {
min-height: 18px !important;
height: 18px !important;
}
}
.statusCell {
text-align: right;
}
.rowActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-3);
}
.sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-3);
}
.sourceChipText {
display: block;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sourceMore {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.muted {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,102 @@
import { Badge } from '@signozhq/ui/badge';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SpantypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { motion } from 'motion/react';
import { Mapping } from 'container/LLMObservability/AttributeMapping/types';
import styles from './MapperRow.module.scss';
const MAX_VISIBLE_SOURCES = 3;
const ROW_TRANSITION = { duration: 0.18, ease: 'easeOut' } as const;
const MAX_STAGGERED_ROWS = 6;
const STAGGER_STEP = 0.03;
interface MapperRowProps {
mapper: Mapping;
index: number;
}
function MapperRow({ mapper, index }: MapperRowProps): JSX.Element {
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
return (
<motion.tr
className={styles.mapperRow}
data-testid={`mapper-row-${mapper.id}`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
...ROW_TRANSITION,
delay: Math.min(index, MAX_STAGGERED_ROWS) * STAGGER_STEP,
}}
>
<td className={cx(styles.cell, styles.targetCell)}>
<Typography.Text
truncate={1}
title={mapper.name}
data-testid={`mapper-target-${mapper.id}`}
>
{mapper.name}
</Typography.Text>
</td>
<td className={styles.cell}>
{sources.length === 0 ? (
<span className={styles.muted} data-testid={`mapper-sources-${mapper.id}`}>
</span>
) : (
<div
className={styles.sources}
data-testid={`mapper-sources-${mapper.id}`}
>
{visibleSources.map((source) => (
<Badge
variant="outline"
color="vanilla"
className={styles.sourceChip}
key={`${source.context}:${source.key}`}
>
<span className={styles.sourceChipText} title={source.key}>
{source.key}
</span>
</Badge>
))}
{remainingSources > 0 && (
<span className={cx(styles.sourceMore, styles.muted)}>
+{remainingSources} more
</span>
)}
</div>
)}
</td>
<td className={styles.cell}>
<Badge
color={
mapper.fieldContext === SpantypesFieldContextDTO.resource
? 'amber'
: 'robin'
}
variant="outline"
>
{mapper.fieldContext}
</Badge>
</td>
<td className={cx(styles.cell, styles.statusCell)}>
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
disabled
testId={`mapper-enabled-${mapper.id}`}
/>
</div>
</td>
</motion.tr>
);
}
export default MapperRow;

View File

@@ -0,0 +1,30 @@
import { Skeleton } from 'antd';
import cx from 'classnames';
import styles from './MapperRow.module.scss';
function MapperRowSkeleton(): JSX.Element {
return (
<tr className={styles.mapperRow}>
<td className={cx(styles.skeletonCell, styles.targetCell)}>
<Skeleton.Input active size="small" style={{ width: '55%' }} />
</td>
<td className={styles.skeletonCell}>
<div className={styles.sources}>
<Skeleton.Button active size="small" style={{ width: 88 }} />
<Skeleton.Button active size="small" style={{ width: 56 }} />
</div>
</td>
<td className={styles.skeletonCell}>
<Skeleton.Button active size="small" style={{ width: 72 }} />
</td>
<td className={cx(styles.skeletonCell, styles.statusCell)}>
<div className={styles.rowActions}>
<Skeleton.Button active size="small" shape="round" />
</div>
</td>
</tr>
);
}
export default MapperRowSkeleton;

View File

@@ -0,0 +1,2 @@
export { default } from './MapperRow';
export { default as MapperRowSkeleton } from './MapperRowSkeleton';

View File

@@ -0,0 +1,11 @@
.colTarget {
width: 32%;
}
.colWritesTo {
width: 140px;
}
.colStatus {
width: 120px;
}

View File

@@ -0,0 +1,14 @@
import styles from './MappingsColgroup.module.scss';
function MappingsColgroup(): JSX.Element {
return (
<colgroup>
<col className={styles.colTarget} />
<col />
<col className={styles.colWritesTo} />
<col className={styles.colStatus} />
</colgroup>
);
}
export default MappingsColgroup;

View File

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

View File

@@ -0,0 +1,124 @@
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.headerRow {
border-bottom: 1px solid var(--l2-border);
}
.headerCell {
padding: var(--spacing-4) var(--spacing-6);
text-align: left;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
&:last-child {
text-align: right;
}
}
.groupsCollapse:global(.ant-collapse) {
background: transparent;
border: none;
border-radius: 0;
> :global(.ant-collapse-item) {
border-bottom: none;
border-top: 1px solid var(--l2-border);
border-radius: 0;
&:last-child {
border-bottom: 1px solid var(--l2-border);
border-radius: 0;
}
> :global(.ant-collapse-header) {
align-items: center;
gap: var(--spacing-3);
background: var(--l2-background);
border-radius: 0;
padding: var(--spacing-3) var(--spacing-6);
color: var(--l3-foreground);
:global(.ant-collapse-expand-icon) {
display: flex;
align-items: center;
height: auto;
padding-inline-end: 0;
color: var(--l3-foreground);
}
:global(.ant-collapse-header-text) {
min-width: 0;
}
:global(.ant-collapse-extra) {
display: flex;
align-items: center;
}
}
}
:global(.ant-collapse-content) {
background: transparent;
border-top: none;
color: inherit;
> :global(.ant-collapse-content-box) {
padding: 0;
}
}
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.skeletonList {
display: flex;
flex-direction: column;
}
// Mirrors the Collapse header banner while groups load.
.skeletonBanner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
&:last-child {
border-bottom: 1px solid var(--l2-border);
}
:global(.ant-skeleton-input) {
min-height: 18px !important;
height: 18px !important;
}
:global(.ant-skeleton-button) {
min-height: 18px !important;
height: 18px !important;
}
}
.skeletonGroupLeft {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex: 1;
min-width: 0;
}
.skeletonGroupRight {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex-shrink: 0;
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Collapse, type CollapseProps, Skeleton } from 'antd';
import { AttributeMappingStore } from 'container/LLMObservability/AttributeMapping/AttributeMappingsTab/hooks/useAttributeMappingStore';
import GroupHeader from './GroupHeader';
import GroupHeaderActions from './GroupHeaderActions';
import GroupMappers from './GroupMappers';
import MappingsColgroup from './MappingsColgroup';
import styles from './MappingsTable.module.scss';
const SKELETON_ROW_COUNT = 3;
interface MappingsTableProps {
store: AttributeMappingStore;
}
function MappingsTable({ store }: MappingsTableProps): JSX.Element {
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const isEmpty = !store.isLoading && store.groups.length === 0;
const items: CollapseProps['items'] = store.groups.map((group) => ({
key: group.id,
label: <GroupHeader group={group} />,
extra: <GroupHeaderActions group={group} />,
children: <GroupMappers group={group} />,
}));
const skeletonBanners = (
<div className={styles.skeletonList}>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={`group-skeleton-${index}`}
className={styles.skeletonBanner}
>
<div className={styles.skeletonGroupLeft}>
<Skeleton.Input
active
size="small"
style={{ width: index % 2 === 0 ? 200 : 140 }}
/>
<Skeleton.Input active size="small" style={{ width: 64 }} />
</div>
<div className={styles.skeletonGroupRight}>
<Skeleton.Button active size="small" shape="round" />
</div>
</div>
))}
</div>
);
if (isEmpty) {
return (
<div className={styles.tableEmpty} data-testid="mapper-groups-empty">
No mapping groups yet.
</div>
);
}
return (
<div data-testid="mappings-table">
<table className={styles.table}>
<MappingsColgroup />
<thead>
<tr className={styles.headerRow}>
<th className={styles.headerCell}>Target</th>
<th className={styles.headerCell}>Sources</th>
<th className={styles.headerCell}>Writes to</th>
<th className={styles.headerCell}>Status</th>
</tr>
</thead>
</table>
{store.isLoading ? (
skeletonBanners
) : (
<Collapse
className={styles.groupsCollapse}
activeKey={expandedGroups}
onChange={(keys): void =>
setExpandedGroups(Array.isArray(keys) ? keys : [keys])
}
bordered={false}
destroyInactivePanel
expandIcon={({ isActive }): JSX.Element =>
isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
items={items}
/>
)}
</div>
);
}
export default MappingsTable;

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react';
import { SpantypesSpanMapperGroupDTO } from 'api/generated/services/sigNoz.schemas';
import { useListSpanMapperGroups } from 'api/generated/services/spanmapper';
import { MappingGroup } from 'container/LLMObservability/AttributeMapping/types';
import { buildMappingGroup } from 'container/LLMObservability/AttributeMapping/utils';
export interface AttributeMappingStore {
groups: MappingGroup[];
isLoading: boolean;
isError: boolean;
}
// Read-only store for the listing view: loads the server groups only. Each
// group's mappers are fetched lazily when its panel is expanded (see
// GroupMappers), so page load is a single request instead of an N+1 fan-out
// across every group. Editing (enabled toggles, save/discard) and its drawers
// land in a later PR — this PR only lists.
export function useAttributeMappingStore(): AttributeMappingStore {
const groupsQuery = useListSpanMapperGroups();
const groups = useMemo<MappingGroup[]>(() => {
const serverGroups: SpantypesSpanMapperGroupDTO[] =
groupsQuery.data?.data?.items ?? [];
return serverGroups.map((group) => buildMappingGroup(group));
}, [groupsQuery.data]);
return {
groups,
isLoading: groupsQuery.isLoading,
isError: groupsQuery.isError,
};
}

View File

@@ -2,12 +2,5 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
padding: var(--spacing-0);
}

View File

@@ -1,9 +1,27 @@
import { Tabs } from '@signozhq/ui/tabs';
import AttributeMappingHeader from './components/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
const tabItems = [
{
key: 'attribute-mappings',
label: 'Attribute mappings',
children: <AttributeMappingsTab />,
},
{
key: 'test',
label: 'Test',
disabled: true,
disabledReason: 'Coming soon',
children: null,
},
];
return (
<div
className={styles.llmObservabilityAttributeMapping}
@@ -16,9 +34,11 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
onSave={noop}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
items={tabItems}
/>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { rest, server } from 'mocks-server/server';
import { render, screen } from 'tests/test-utils';
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
function setupGroups(): void {
server.use(
rest.get(GROUPS_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(makeGroupsResponse(mockGroups))),
),
);
}
describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
});
afterEach(() => {
server.resetHandlers();
});
it('renders the page shell', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('shows the attribute-mappings and test sub-tab labels', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByRole('tab', { name: 'Attribute mappings' }),
).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Test' })).toBeInTheDocument();
});
it('activates the attribute-mappings tab by default and renders its content', async () => {
render(<LLMObservabilityAttributeMapping />);
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute mappings',
});
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders the header with its description and no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('unsaved-changes')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import {
SpantypesFieldContextDTO as FieldContext,
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
} from 'api/generated/services/sigNoz.schemas';
// Endpoint globs used by MSW handlers. The generated client hits relative
// `/api/v1/span_mapper_groups[...]`, so the `*` prefix matches regardless of
// base URL.
export const GROUPS_ENDPOINT = '*/api/v1/span_mapper_groups';
export function mappersEndpoint(groupId: string): string {
return `*/api/v1/span_mapper_groups/${groupId}/span_mappers`;
}
export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
return {
id: 'group-1',
orgId: 'org-1',
name: 'demo',
enabled: true,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
...overrides,
};
}
export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
return {
id: 'mapper-1',
group_id: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
fieldContext: FieldContext.attribute,
config: {
sources: [
{
key: 'genai.model',
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
},
],
},
...overrides,
};
}
// Both list endpoints share the same `{ status, data: { items } }` envelope —
// the generated schema mis-types the mappers response with the groups DTO
// (see GroupMappers), but the runtime envelope shape is identical.
export function makeGroupsResponse(groups: MapperGroup[]): {
status: string;
data: { items: MapperGroup[] };
} {
return { status: 'ok', data: { items: groups } };
}
export function makeMappersResponse(mappers: Mapper[]): {
status: string;
data: { items: Mapper[] };
} {
return { status: 'ok', data: { items: mappers } };
}
export const mockGroups: MapperGroup[] = [
makeGroup({
id: 'group-1',
name: 'demo',
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
},
}),
makeGroup({
id: 'group-2',
name: 'Tool',
enabled: false,
condition: { attributes: null, resource: null },
}),
];
export const mockMappers: Mapper[] = [
makeMapper({ id: 'mapper-1', group_id: 'group-1' }),
];

View File

@@ -5,23 +5,6 @@
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;

View File

@@ -1,4 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './AttributeMappingHeader.module.scss';
@@ -17,38 +18,35 @@ function AttributeMappingHeader({
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,26 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperOperationDTO,
} from 'api/generated/services/sigNoz.schemas';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
export interface Mapping {
id: string;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
export interface MappingGroup {
id: string;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
}

View File

@@ -0,0 +1,48 @@
import {
ListSpanMappers200,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MappingGroup, Mapping, SourceConfig } from './types';
function getMapperSources(mapper: SpantypesSpanMapperDTO): SourceConfig[] {
const sources = mapper.config?.sources ?? [];
return [...sources]
.sort((a, b) => a.priority - b.priority)
.map((source) => ({
key: source.key,
context: source.context,
operation: source.operation,
}));
}
export function buildMapping(mapper: SpantypesSpanMapperDTO): Mapping {
return {
id: mapper.id,
name: mapper.name,
fieldContext: mapper.fieldContext,
sources: getMapperSources(mapper),
enabled: mapper.enabled,
};
}
export function buildMappingsFromListResponse(
response: ListSpanMappers200,
): Mapping[] {
const items = (response.data?.items ??
[]) as unknown as SpantypesSpanMapperDTO[];
return items.map(buildMapping);
}
export function buildMappingGroup(
group: SpantypesSpanMapperGroupDTO,
): MappingGroup {
return {
id: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
enabled: group.enabled,
};
}

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

@@ -208,7 +208,7 @@ describe('ServiceAccountsSettings (integration)', () => {
fireEvent.click(screen.getByRole('button', { name: /New Service Account/i }));
await screen.findByRole('dialog', { name: /New Service Account/i });
await screen.findByTestId('create-service-account-modal');
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
});

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

@@ -0,0 +1,130 @@
import { act, renderHook } from '@testing-library/react';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
import { useClientExport } from '../useClientExport';
jest.mock('lib/exportData/downloadFile', () => ({
...jest.requireActual('lib/exportData/downloadFile'),
downloadFile: jest.fn(),
}));
const mockMessageError = jest.fn();
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: { error: (...args: unknown[]): void => mockMessageError(...args) },
};
});
const mockDownloadFile = downloadFile as jest.Mock;
function timeSeriesResponse(): QueryRangeResponseV5 {
return {
type: 'time_series',
data: {
results: [
{
queryName: 'A',
aggregations: [
{
index: 0,
alias: '',
meta: {},
series: [
{
labels: [{ key: { name: 'service' }, value: 'a' }],
values: [{ timestamp: 1000, value: 12 }],
},
],
},
],
},
],
},
meta: {},
} as unknown as QueryRangeResponseV5;
}
describe('useClientExport', () => {
beforeEach(() => {
jest.clearAllMocks();
// Freeze the clock so filenames are deterministic — asserted against the
// real getTimestampedFileName (the format itself is pinned by an exact
// string in downloadFile.test).
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 13, 14, 32, 5));
});
afterEach(() => {
jest.useRealTimers();
});
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
const { result } = renderHook(() =>
useClientExport({
response: timeSeriesResponse(),
fileName: 'chart',
legendMap: { A: '{{service}}' },
}),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [content, name, mime] = mockDownloadFile.mock.calls[0];
// delegation: the hook names files via getTimestampedFileName
expect(name).toBe(getTimestampedFileName('chart', 'csv'));
expect(mime).toContain('text/csv');
expect(content).toContain('service');
expect(content).toContain('a');
});
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
const { result } = renderHook(() =>
useClientExport({ response: timeSeriesResponse() }),
);
act(() => {
result.current.handleExport({ format: ExportFormat.Jsonl });
});
const [content, name, mime] = mockDownloadFile.mock.calls[0];
expect(name).toBe(getTimestampedFileName('export', 'jsonl'));
expect(mime).toContain('ndjson');
expect(content).toContain('"series"');
});
it('does nothing when there is no response', () => {
const { result } = renderHook(() => useClientExport({}));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).not.toHaveBeenCalled();
});
it('shows an error and does not download for unsupported result types', () => {
const raw = {
type: 'raw',
data: { results: [] },
meta: {},
} as unknown as QueryRangeResponseV5;
const { result } = renderHook(() => useClientExport({ response: raw }));
act(() => {
result.current.handleExport({ format: ExportFormat.Csv });
});
expect(mockDownloadFile).not.toHaveBeenCalled();
expect(mockMessageError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,93 @@
import { message } from 'antd';
import { REQUEST_TYPES } from 'api/v5/queryRange/constants';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
import { toCsv } from 'lib/exportData/toCsv';
import { toJsonl } from 'lib/exportData/toJsonl';
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
import { useCallback, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
[ExportFormat.Csv]: { mime: 'text/csv;charset=utf-8;', extension: 'csv' },
[ExportFormat.Jsonl]: {
mime: 'application/x-ndjson;charset=utf-8;',
extension: 'jsonl',
},
};
// Picks the serializer for the response's request type. Narrows the results
// union via the response discriminant. scalar lands with #5591; raw/trace are
// server-exported, distribution is never emitted.
function serialize(
response: QueryRangeResponseV5,
yAxisUnit?: string,
legendMap?: Record<string, string>,
query?: Query,
): SerializedTable {
if (response.type === REQUEST_TYPES.TIME_SERIES) {
return exportTimeseriesData({
data: response.data.results as TimeSeriesData[],
yAxisUnit,
legendMap,
query,
});
}
throw new Error(`Export is not supported for "${response.type}" results`);
}
interface UseClientExportProps {
response?: QueryRangeResponseV5;
query?: Query;
yAxisUnit?: string;
fileName?: string;
legendMap?: Record<string, string>;
}
interface ClientExportOptions {
format: ExportFormat;
}
interface UseClientExportReturn {
isExporting: boolean;
handleExport: (options: ClientExportOptions) => void;
}
export function useClientExport({
response, // currently supports only qb v5 response. Can extend to support future responses.
query,
yAxisUnit,
fileName = 'export',
legendMap,
}: UseClientExportProps): UseClientExportReturn {
const [isExporting, setIsExporting] = useState<boolean>(false);
const handleExport = useCallback(
({ format }: ClientExportOptions): void => {
if (!response) {
return;
}
setIsExporting(true);
try {
const table = serialize(response, yAxisUnit, legendMap, query);
const content =
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
const { mime, extension } = FORMAT_META[format];
downloadFile(content, getTimestampedFileName(fileName, extension), mime);
} catch {
message.error('Failed to export data. Please try again.');
} finally {
setIsExporting(false);
}
},
[response, query, yAxisUnit, fileName, legendMap],
);
return { isExporting, handleExport };
}

View File

@@ -0,0 +1,56 @@
import { downloadFile, getTimestampedFileName } from '../downloadFile';
// jsdom doesn't implement the object-URL APIs; define stubs so jest.spyOn can wrap them.
if (typeof URL.createObjectURL !== 'function') {
URL.createObjectURL = (): string => '';
}
if (typeof URL.revokeObjectURL !== 'function') {
URL.revokeObjectURL = (): void => undefined;
}
describe('downloadFile', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('builds a blob anchor, clicks it, and revokes the object URL', () => {
const click = jest.fn();
const remove = jest.fn();
const anchor = {
href: '',
download: '',
click,
remove,
} as unknown as HTMLAnchorElement;
(
jest.spyOn(document, 'createElement') as unknown as jest.Mock
).mockReturnValue(anchor);
const createObjectURL = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock');
const revokeObjectURL = jest.spyOn(URL, 'revokeObjectURL');
downloadFile('hello', 'export.csv', 'text/csv');
expect(anchor.download).toBe('export.csv');
expect(anchor.href).toBe('blob:mock');
expect(click).toHaveBeenCalledTimes(1);
expect(createObjectURL).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
});
});
describe('getTimestampedFileName', () => {
afterEach(() => {
jest.useRealTimers();
});
it('appends a local timestamp between base and extension', () => {
jest.useFakeTimers().setSystemTime(new Date(2026, 6, 8, 14, 32, 5));
expect(getTimestampedFileName('logs-timeseries', 'csv')).toBe(
'logs-timeseries-2026-07-08_14-32-05.csv',
);
});
});

View File

@@ -0,0 +1,188 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { exportTimeseriesData } from '../exportTimeseriesData';
const iso = (ms: number): string => new Date(ms).toISOString();
function makeSeries(
labels: Record<string, string>,
values: [number, number][],
): TimeSeries {
return {
labels: Object.entries(labels).map(([name, value]) => ({
key: { name },
value,
})),
values: values.map(([timestamp, value]) => ({ timestamp, value })),
};
}
function makeQuery(
queryName: string,
buckets: { index?: number; alias?: string; series: TimeSeries[] }[],
): TimeSeriesData {
return {
queryName,
aggregations: buckets.map((bucket, i) => ({
index: bucket.index ?? i,
alias: bucket.alias ?? '',
meta: {},
series: bucket.series,
})),
};
}
describe('exportTimeseriesData', () => {
it('one row per point: query column, label columns, unit in value header, legend naming', () => {
const data = [
makeQuery('A', [
{
series: [
makeSeries({ service_name: 'frontend' }, [
[1000, 12],
[2000, 15],
]),
],
},
]),
];
const table = exportTimeseriesData({
data,
yAxisUnit: 'ms',
legendMap: { A: '{{service_name}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service_name',
'value (ms)',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'frontend', 'frontend', 12],
[iso(2000), 'A', 'frontend', 'frontend', 15],
]);
});
it('no legend falls back to the label-set name from getLabelName', () => {
const data = [
makeQuery('A', [
{ series: [makeSeries({ service_name: 'frontend' }, [[1000, 12]])] },
]),
];
const table = exportTimeseriesData({ data });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', '{service_name="frontend"}', 'frontend', 12],
]);
});
it('multi-query: query is its own column; label keys are unioned', () => {
const data = [
makeQuery('A', [{ series: [makeSeries({ service: 'x' }, [[1000, 1]])] }]),
makeQuery('B', [{ series: [makeSeries({ service: 'y' }, [[1000, 2]])] }]),
];
const table = exportTimeseriesData({
data,
legendMap: { A: '{{service}}', B: '{{service}}' },
});
expect(table.headers).toStrictEqual([
'timestamp',
'query',
'series',
'service',
'value',
]);
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'x', 'x', 1],
[iso(1000), 'B', 'y', 'y', 2],
]);
});
it('multi-aggregation with the builder query: names match the chart legend', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
{
index: 1,
alias: '__result_1',
series: [makeSeries({}, [[1000, 300]])],
},
]),
makeQuery('B', [
{
index: 0,
alias: '__result_0',
series: [
makeSeries({ 'cloud.account.id': 'signoz-staging' }, [[1000, 7]]),
],
},
]),
];
const query = {
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregations: [
{ expression: 'count()' },
{ expression: 'avg(code.lineno)' },
],
groupBy: [],
},
{
queryName: 'B',
dataSource: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ key: 'cloud.account.id' }],
},
],
queryFormulas: [],
},
} as unknown as Query;
const table = exportTimeseriesData({ data, query });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'count()-A', '', 5],
[iso(1000), 'A', 'avg(code.lineno)-A', '', 300],
[iso(1000), 'B', '{cloud.account.id="signoz-staging"}', 'signoz-staging', 7],
]);
});
it('multi-aggregation without the builder query: falls back to base names', () => {
const data = [
makeQuery('A', [
{ index: 0, alias: '__result_0', series: [makeSeries({}, [[1000, 5]])] },
{
index: 1,
alias: '__result_1',
series: [makeSeries({}, [[1000, 300]])],
},
]),
];
const table = exportTimeseriesData({ data });
expect(table.rows).toStrictEqual([
[iso(1000), 'A', 'A', 5],
[iso(1000), 'A', 'A', 300],
]);
});
it('empty data: returns a headers-only table', () => {
expect(exportTimeseriesData({ data: [] })).toStrictEqual({
headers: ['timestamp', 'query', 'series', 'value'],
rows: [],
});
});
});

View File

@@ -0,0 +1,42 @@
import { toCsv } from '../toCsv';
import { toJsonl } from '../toJsonl';
import { SerializedTable } from '../types';
const table: SerializedTable = {
headers: ['timestamp', 'value'],
rows: [
['t1', 12],
['t2', 15],
],
};
describe('toCsv', () => {
it('emits a header row then one row per record, in column order', () => {
expect(toCsv(table).split(/\r?\n/)).toStrictEqual([
'timestamp,value',
't1,12',
't2,15',
]);
});
it('quotes values containing the delimiter', () => {
const csv = toCsv({ headers: ['name', 'value'], rows: [['a,b', 1]] });
expect(csv.split(/\r?\n/)).toStrictEqual(['name,value', '"a,b",1']);
});
it('emits only the header row when there are no data rows', () => {
expect(toCsv({ headers: ['timestamp'], rows: [] })).toBe('timestamp\r\n');
});
});
describe('toJsonl', () => {
it('emits one JSON object per row keyed by header', () => {
expect(toJsonl(table)).toBe(
'{"timestamp":"t1","value":12}\n{"timestamp":"t2","value":15}',
);
});
it('emits an empty string when there are no rows', () => {
expect(toJsonl({ headers: ['timestamp'], rows: [] })).toBe('');
});
});

View File

@@ -0,0 +1,29 @@
/** Triggers a browser download of in-memory string content as a file. */
export function downloadFile(
content: string,
fileName: string,
mime: string,
): void {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
link.remove();
URL.revokeObjectURL(url);
}
/** `base` + local timestamp + extension, e.g. `logs-timeseries-2026-07-08_14-32-05.csv`.
* Keeps repeated exports from colliding and records when the export was taken. */
export function getTimestampedFileName(
base: string,
extension: string,
): string {
const now = new Date();
const pad = (value: number): string => String(value).padStart(2, '0');
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate(),
)}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
return `${base}-${stamp}.${extension}`;
}

View File

@@ -0,0 +1,154 @@
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TimeSeries, TimeSeriesData } from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { SerializedTable } from './types';
interface ExportTimeseriesDataArgs {
data: TimeSeriesData[];
yAxisUnit?: string;
legendMap?: Record<string, string>;
// The builder query that produced the data — lets series names resolve
// aggregation aliases/expressions exactly like the chart legend does.
query?: Query;
}
// One row of the flattened V5 tree: a single (query, aggregation, label-set) series.
interface FlatSeries {
queryName: string;
labels: Record<string, string>;
name: string;
values: { timestamp: number; value: number }[];
}
// V5 labels [{key:{name}, value}] → {name: value} (the getLabelName contract).
function foldLabels(labels: TimeSeries['labels']): Record<string, string> {
const record: Record<string, string> = {};
(labels ?? []).forEach((label) => {
if (label.key?.name) {
record[label.key.name] = String(label.value);
}
});
return record;
}
// Series display name, matching the chart legend: getLabelName for the base
// (legend template / label-set), then getLegend to resolve the aggregation
// alias/expression from the builder query (the response only carries
// auto-generated `__result_N` aliases). Same chain the uPlot layer uses.
function seriesName(args: {
labels: Record<string, string>;
queryName: string;
legend: string;
aggIndex: number;
alias: string;
query?: Query;
}): string {
const { labels, queryName, legend, aggIndex, alias, query } = args;
const baseName = getLabelName(labels, queryName, legend);
if (!query) {
return baseName;
}
const legacySeries = {
queryName,
metric: labels,
values: [],
metaData: { alias, index: aggIndex, queryName },
} as QueryData;
return getLegend(legacySeries, query, baseName);
}
// Walk results → aggregations → series into a flat, named list.
function flatten(
data: TimeSeriesData[],
legendMap?: Record<string, string>,
query?: Query,
): FlatSeries[] {
const flat: FlatSeries[] = [];
data.forEach((result) => {
const queryName = result.queryName ?? '';
const legend = legendMap?.[queryName] ?? '';
(result.aggregations ?? []).forEach((bucket) => {
(bucket.series ?? []).forEach((series) => {
const labels = foldLabels(series.labels);
flat.push({
queryName,
labels,
name: seriesName({
labels,
queryName,
legend,
aggIndex: bucket.index ?? 0,
alias: bucket.alias ?? '',
query,
}),
values: (series.values ?? []).map((value) => ({
timestamp: value.timestamp,
value: value.value,
})),
});
});
});
});
return flat;
}
// Appends the y-axis unit to the value header: `value` → `value (ms)`.
function withUnit(header: string, yAxisUnit?: string): string {
return yAxisUnit ? `${header} (${yAxisUnit})` : header;
}
function toIso(timestamp: number): string {
return new Date(timestamp).toISOString();
}
// Tidy (LONG) layout: one row per (series, timestamp). query is its own column.
function buildTable(flat: FlatSeries[], yAxisUnit?: string): SerializedTable {
const labelKeySet = new Set<string>();
flat.forEach((series) => {
Object.keys(series.labels).forEach((key) => labelKeySet.add(key));
});
const labelKeys = Array.from(labelKeySet).sort();
const headers = [
'timestamp',
'query',
'series',
...labelKeys,
withUnit('value', yAxisUnit),
];
const rows: (string | number)[][] = [];
flat.forEach((series) => {
series.values.forEach(({ timestamp, value }) => {
rows.push([
toIso(timestamp),
series.queryName,
series.name,
...labelKeys.map((key) => series.labels[key] ?? ''),
value,
]);
});
});
return { headers, rows };
}
/**
* Serializes a V5 time_series result into a format-agnostic tidy table — one
* row per (series, timestamp), labels as columns, raw values.
* Pure — walks the V5 tree directly; series names match the chart legend.
*/
export function exportTimeseriesData({
data,
yAxisUnit,
legendMap,
query,
}: ExportTimeseriesDataArgs): SerializedTable {
return buildTable(flatten(data, legendMap, query), yAxisUnit);
}

View File

@@ -0,0 +1,8 @@
import { unparse } from 'papaparse';
import { SerializedTable } from './types';
/** Serializes a table to CSV. `fields` pins column order regardless of row keys. */
export function toCsv(table: SerializedTable): string {
return unparse({ fields: table.headers, data: table.rows });
}

View File

@@ -0,0 +1,12 @@
import { SerializedTable } from './types';
/** Serializes a table to newline-delimited JSON: one object per row, keyed by header. */
export function toJsonl(table: SerializedTable): string {
return table.rows
.map((row) =>
JSON.stringify(
Object.fromEntries(table.headers.map((header, i) => [header, row[i]])),
),
)
.join('\n');
}

View File

@@ -0,0 +1,13 @@
/** Format-agnostic tabular result produced by every exporter. Consumed by the
* CSV/JSONL formatters */
export interface SerializedTable {
headers: string[];
// One entry per header, in header order. Empty string marks a gap.
rows: (string | number)[][];
}
/** File formats a client-side export can be downloaded as. */
export enum ExportFormat {
Csv = 'csv',
Jsonl = 'jsonl',
}

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

@@ -1,5 +1,10 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
clearViewPanelHandoff,
readViewPanelHandoff,
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useSwitchToViewMode } from '../useSwitchToViewMode';
@@ -18,11 +23,16 @@ jest.mock('hooks/useUrlQuery', () => ({
}));
const query = { queryType: 'builder' } as unknown as Query;
const spec = {
plugin: { kind: 'signoz/TimeSeriesPanel' },
display: { name: 'CPU' },
} as unknown as DashboardtypesPanelSpecDTO;
describe('useSwitchToViewMode', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSearch = '';
clearViewPanelHandoff();
});
function invoke(): void {
@@ -32,6 +42,7 @@ describe('useSwitchToViewMode', () => {
panelId: 'panel-1',
panelType: PANEL_TYPES.TIME_SERIES,
query,
spec,
}),
);
result.current();
@@ -52,6 +63,21 @@ describe('useSwitchToViewMode', () => {
).toStrictEqual(query);
});
it('stashes the live draft spec in the sessionStorage handoff, not the URL', () => {
invoke();
expect(readViewPanelHandoff('dash-1', 'panel-1')).toStrictEqual(spec);
// The spec must not bloat the URL — the config-only display name never leaks into it.
expect(mockSafeNavigate.mock.calls[0][0]).not.toContain('CPU');
});
it('scopes the handoff to the exact dashboard + panel', () => {
invoke();
expect(readViewPanelHandoff('dash-1', 'other-panel')).toBeNull();
expect(readViewPanelHandoff('other-dash', 'panel-1')).toBeNull();
});
it('carries dashboard variables through and drops other editor URL state', () => {
mockSearch = 'variables=%7B%22a%22%3A1%7D&compositeQuery=stale';
invoke();

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { generatePath } from 'react-router-dom';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -7,27 +8,35 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { writeViewPanelHandoff } from '../../PanelsAndSectionsLayout/Panel/ViewPanelModal/viewPanelHandoffStore';
interface UseSwitchToViewModeArgs {
dashboardId: string;
panelId: string;
panelType: PANEL_TYPES;
query: Query;
/** Live (un-saved) draft spec — the query rides in the URL, the rest via the handoff. */
spec: DashboardtypesPanelSpecDTO;
}
/**
* Callback that leaves the editor for the dashboard with this panel expanded in the
* View modal, seeded with the live (un-saved) query — V1's "Switch to View Mode".
* Leaves the editor for the dashboard with this panel expanded in the View modal, seeded with
* the live (un-saved) query + config — V1's "Switch to View Mode". The query rides in the URL
* (`compositeQuery`); the rest of the spec rides in a tab-scoped sessionStorage handoff.
*/
export function useSwitchToViewMode({
dashboardId,
panelId,
panelType,
query,
spec,
}: UseSwitchToViewModeArgs): () => void {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
return useCallback((): void => {
writeViewPanelHandoff({ dashboardId, panelId, spec });
const params = new URLSearchParams();
const variables = urlQuery.get(QueryParams.variables);
if (variables) {
@@ -42,5 +51,5 @@ export function useSwitchToViewMode({
safeNavigate(
`${generatePath(ROUTES.DASHBOARD, { dashboardId })}?${params.toString()}`,
);
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query]);
}, [safeNavigate, urlQuery, dashboardId, panelId, panelType, query, spec]);
}

View File

@@ -38,6 +38,8 @@ import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
interface PanelEditorContainerProps {
dashboardId: string;
@@ -204,6 +206,7 @@ function PanelEditorContainer({
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
@@ -234,6 +237,13 @@ function PanelEditorContainer({
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<div className={styles.page} data-testid="panel-editor-v2">
<Header
@@ -243,7 +253,7 @@ function PanelEditorContainer({
readOnly={!isEditable}
readOnlyReason={editDisabledReason}
onSave={onSave}
onSwitchToView={onSwitchToView}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup

View File

@@ -16,6 +16,8 @@ import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import { DashboardEvents } from 'pages/DashboardPageV2/constants/events';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
@@ -97,6 +99,14 @@ function ViewPanelModalContent({
return null;
}
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
};
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
@@ -114,10 +124,7 @@ function ViewPanelModalContent({
refreshWindow();
}
}}
onSwitchToEdit={(): void =>
// Carry the drilldown edits so the editor opens on them, not the saved panel.
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}

View File

@@ -13,6 +13,7 @@ import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/
import type { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
interface ViewPanelModalHeaderProps {
selectedInterval: Time | CustomTimeType;
@@ -64,6 +65,10 @@ function ViewPanelModalHeader({
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const canSwitchToEdit = canEditDashboard && !isLocked;
return (
<div className={styles.toolbar}>
@@ -75,15 +80,17 @@ function ViewPanelModalHeader({
onChange={onChangePanelKind}
/>
</div>
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
{canSwitchToEdit && (
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
)}
<Button
variant="link"
color="primary"

View File

@@ -23,8 +23,11 @@ import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { EQueryType } from 'types/common/dashboard';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
@@ -77,25 +80,33 @@ export function useViewPanelMode({
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
const urlQuery = useGetCompositeQueryParam();
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() =>
urlQuery
compositeQuery
? {
...panel,
spec: buildViewPanelSpec({
spec: panel.spec,
query: urlQuery,
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: panel,
: { ...panel, spec: baseSpec },
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);

View File

@@ -0,0 +1,43 @@
import getSessionStorage from 'api/browser/sessionstorage/get';
import removeSessionStorage from 'api/browser/sessionstorage/remove';
import setSessionStorage from 'api/browser/sessionstorage/set';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { SESSIONSTORAGE } from 'constants/sessionStorage';
interface ViewPanelHandoff {
/** Correlator: the read returns the spec only for this exact dashboard + panel. */
dashboardId: string;
panelId: string;
spec: DashboardtypesPanelSpecDTO;
}
/**
* Tab-scoped handoff of the editor's un-saved draft spec to the View modal, so "Switch to View
* Mode" carries config edits — not just the query, which stays in the URL. sessionStorage keeps
* the link small yet survives a refresh, and clears the edits when the tab closes.
*/
export function writeViewPanelHandoff(handoff: ViewPanelHandoff): void {
setSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF, JSON.stringify(handoff));
}
export function readViewPanelHandoff(
dashboardId: string,
panelId: string,
): DashboardtypesPanelSpecDTO | null {
const raw = getSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
if (!raw) {
return null;
}
try {
const handoff = JSON.parse(raw) as ViewPanelHandoff;
return handoff.dashboardId === dashboardId && handoff.panelId === panelId
? handoff.spec
: null;
} catch {
return null;
}
}
export function clearViewPanelHandoff(): void {
removeSessionStorage(SESSIONSTORAGE.VIEW_PANEL_HANDOFF);
}

View File

@@ -6,6 +6,8 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { clearViewPanelHandoff } from '../ViewPanelModal/viewPanelHandoffStore';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
@@ -41,10 +43,11 @@ export function useViewPanel(): UseViewPanelApi {
// Copy before mutating: useUrlQuery returns a memoized instance.
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
// Drop any leftover in-modal query/kind so a plain View opens on the saved
// panel, not a stale URL query the modal would otherwise hydrate from.
// Drop leftover in-modal query/kind + the editor's handoff so a plain View opens
// on the saved panel, not stale state the modal would otherwise hydrate from.
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
@@ -55,6 +58,8 @@ export function useViewPanel(): UseViewPanelApi {
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
next.set(QueryParams.graphType, panelType);
// A grid drilldown opens on the saved panel, never a stale editor handoff.
clearViewPanelHandoff();
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
@@ -73,6 +78,7 @@ export function useViewPanel(): UseViewPanelApi {
// (the in-modal query builder writes compositeQuery, V1 parity).
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
const search = next.toString();
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);

View File

@@ -3,7 +3,10 @@ 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 } from 'constants/queryCacheTime';
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';
@@ -48,9 +51,10 @@ function DynamicSelector({
onChange,
onAutoSelect,
}: DynamicSelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const existingQuery = useMemo(
() => buildExistingDynamicVariableQuery(variables, selections, variable.name),
@@ -96,8 +100,10 @@ function DynamicSelector({
!!variable.dynamicAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
// 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)

View File

@@ -3,7 +3,10 @@ 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 } from 'constants/queryCacheTime';
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';
@@ -44,9 +47,10 @@ function QuerySelector({
onChange,
onAutoSelect,
}: QuerySelectorProps): JSX.Element {
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const payload = useMemo(() => selectionToPayload(selections), [selections]);
const {
@@ -80,8 +84,10 @@ function QuerySelector({
{
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
refetchOnWindowFocus: false,
// Each cycle mints a fresh key; a small cacheTime bounds cache churn.
cacheTime: DASHBOARD_CACHE_TIME,
// 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)

View File

@@ -2,6 +2,10 @@
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { usePanelQuery } from '../usePanelQuery';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
@@ -432,4 +436,28 @@ describe('usePanelQuery', () => {
expect(result.current.pagination?.pageIndex).toBe(0);
});
});
describe('cacheTime (auto-refresh OOM guard)', () => {
const withAutoRefreshDisabled = (disabled: boolean): void => {
mockUseSelector.mockImplementation((selector: unknown) =>
(selector as (state: { globalTime: unknown }) => unknown)({
globalTime: { ...DEFAULT_GLOBAL_TIME, isAutoRefreshDisabled: disabled },
}),
);
};
it('caches for DASHBOARD_CACHE_TIME when auto-refresh is disabled', () => {
withAutoRefreshDisabled(true);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME);
});
it('drops cacheTime to 0 when auto-refresh is enabled', () => {
withAutoRefreshDisabled(false);
renderHook(() => usePanelQuery({ panel: builderPanel(), panelId: 'p1' }));
const [{ cacheTime }] = mockUseGetQueryRangeV5.mock.calls[0];
expect(cacheTime).toBe(DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED);
});
});
});

View File

@@ -13,6 +13,8 @@ export interface UseGetQueryRangeV5Args {
enabled: boolean;
/** Retain prior data across a key change (list paging) so the table + pager stay mounted. */
keepPreviousData?: boolean;
/** Unused-entry TTL; callers drop to 0 under auto-refresh to bound cache growth (V1 parity). */
cacheTime?: number;
}
/**
@@ -46,6 +48,7 @@ export function useGetQueryRangeV5({
queryKey,
enabled,
keepPreviousData,
cacheTime,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
return useQuery<QueryRangeV5200, Error>({
queryKey,
@@ -53,5 +56,6 @@ export function useGetQueryRangeV5({
enabled,
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,
});
}

View File

@@ -4,6 +4,10 @@ import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -110,6 +114,7 @@ export function usePanelQuery({
selectedTime: globalSelectedInterval,
maxTime,
minTime,
isAutoRefreshDisabled,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
// Resolved variable values for this dashboard, published by useResolvedVariables.
@@ -243,6 +248,10 @@ export function usePanelQuery({
enabled: enabled && runnable && !isWaitingOnVariable,
// Hold the current page while the next loads (offset re-keys) so the pager doesn't flash.
keepPreviousData: isPaginated,
// 0 under auto-refresh so time-keyed entries don't accumulate and OOM the tab (V1 parity).
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
});
const queryClient = useQueryClient();

View File

@@ -0,0 +1,4 @@
export enum DashboardEvents {
SWITCH_TO_EDIT_MODE = 'View Panel: Switch to edit mode',
SWITCH_TO_VIEW_MODE = 'Edit Panel: Switch to view mode',
}

View File

@@ -5,7 +5,7 @@ import { ArrowUpRight } from '@signozhq/icons';
import styles from './MissingSpansBanner.module.scss';
const MISSING_SPANS_DOCS_URL =
'https://signoz.io/docs/userguide/traces/#missing-spans';
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace';
function MissingSpansBanner(): JSX.Element | null {
// Session-only dismissal — not persisted, so the banner returns on reload.

View File

@@ -14,7 +14,8 @@ const DOCLINKS = {
'https://signoz.io/docs/userguide/logs_clickhouse_queries/',
QUERY_CLICKHOUSE_METRICS:
'https://signoz.io/docs/userguide/write-a-metrics-clickhouse-query/',
AGENT_SKILL_INSTALL: 'https://signoz.io/docs/ai/agent-skills/#installation',
AGENT_SKILL_INSTALL:
'https://signoz.io/docs/ai/agent-skills/#install-the-plugin',
};
export default DOCLINKS;

View File

@@ -4,13 +4,26 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/gorilla/mux"
)
func telemetryReadScopes() []string {
return []string{
coretypes.ResourceTelemetryResourceLogs.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceTraces.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceAuditLogs.Scope(coretypes.VerbRead),
coretypes.ResourceTelemetryResourceMeterMetrics.Scope(coretypes.VerbRead),
}
}
func (provider *provider) addQuerierRoutes(router *mux.Router) error {
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRange), handler.OpenAPIDef{
if err := router.Handle("/api/v5/query_range", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"querier"},
Summary: "Query range",
@@ -446,12 +459,17 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.QueryRangeResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.CheckResources(provider.querierHandler.QueryRangePreview, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range preview",
@@ -463,8 +481,13 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes(telemetryReadScopes()),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.QueryRangeResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}

View File

@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
Response: make([]*authtypes.GettableRole, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.RoleWithTransactionGroups),
Response: new(authtypes.Role),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -30,17 +30,14 @@ type AuthZ interface {
// Write accepts the insertion tuples and the deletion tuples.
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
// Creates the role with its transaction groups.
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
Create(context.Context, valuer.UUID, *authtypes.Role) error
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
Update(context.Context, valuer.UUID, *authtypes.Role) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -48,9 +45,6 @@ type AuthZ interface {
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
// Gets the role with transaction groups
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
@@ -80,9 +74,6 @@ type AuthZ interface {
// Bootstrap managed roles transactions and user assignments
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
}
// OnBeforeRoleDelete is a callback invoked before a role is deleted.

View File

@@ -83,10 +83,6 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -181,15 +177,11 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
}
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *authtypes.Role) (*authtypes.Role, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
render.Success(rw, http.StatusOK, role)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
@@ -88,7 +88,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
return
}
render.Success(rw, http.StatusOK, roles)
render.Success(rw, http.StatusOK, authtypes.NewGettableRolesFromRoles(roles))
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
@@ -117,14 +117,13 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
err = role.Update(req.Description, req.TransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
if err != nil {
render.Error(rw, err)
return

View File

@@ -15,8 +15,6 @@ var (
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
)
func MustNewRegistry() featuretypes.Registry {
@@ -117,14 +115,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUsePrometheusClickhouseV2,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Runs PromQL queries on the clickhousev2 provider alongside the served engine result and logs any difference; serving is unaffected.",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -1,6 +1,9 @@
package handler
import "github.com/SigNoz/signoz/pkg/types/coretypes"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
)
type ResourceDef interface {
// resolveRequest is unexported to seal the interface. It returns a slice so a
@@ -97,3 +100,31 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
),
}
}
type TelemetryResourceDef struct {
Verb coretypes.Verb
Category coretypes.ActionCategory
Selector coretypes.SelectorFunc
Resources coretypes.ResourceExtractor
}
func (def TelemetryResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
refs, err := def.Resources(ec)
if err != nil {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(def.Verb, def.Category, err)}
}
if len(refs) == 0 {
return []coretypes.ResolvedResource{coretypes.NewResolvedResourceWithError(
def.Verb,
def.Category,
errors.NewInvalidInputf(errors.CodeInvalidInput, "request resolved to no resources"),
)}
}
resolved := make([]coretypes.ResolvedResource, 0, len(refs))
for _, ref := range refs {
resolved = append(resolved, coretypes.NewResolvedResourceWithID(def.Verb, def.Category, ref.Resource, ref.ID, def.Selector))
}
return resolved
}

View File

@@ -118,6 +118,10 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
extractorCtx := coretypes.ExtractorContext{Request: req, ResponseBody: writer.BodyBytes()}
for _, resource := range resolved {
if err := resource.Err(); err != nil {
continue
}
resource.ResolveResponse(extractorCtx)
verb, category := resource.Verb(), resource.Category()

View File

@@ -1,27 +0,0 @@
{
"id": "cloudsql",
"title": "GCP Cloud SQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL Overview",
"description": "Overview of GCP Cloud SQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Cloud SQL with SigNoz
Collect key GCP Cloud SQL metrics and view them with an out of the box dashboard.

View File

@@ -0,0 +1,106 @@
{
"id": "cloudsql_postgres",
"title": "GCP Cloud SQL for PostgreSQL",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "cloudsql.googleapis.com/database/up",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/usage",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/bytes_used",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/num_backends",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag",
"unit": "Bytes",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud SQL for PostgreSQL Overview",
"description": "Overview of GCP Cloud SQL for PostgreSQL metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,3 @@
### Monitor GCP Cloud SQL for PostgreSQL with SigNoz
Collect key GCP Cloud SQL for PostgreSQL metrics and view them with an out of the box dashboard.

View File

@@ -16,11 +16,11 @@ const (
// Documentation links — one per component. User-facing; emitted on missing-entries.
const (
docLinkHostMetricsReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/hostmetrics/#configure-the-hostmetrics-receiver"
docLinkKubeletStatsReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#setup-kubelet-stats-receiver"
docLinkK8sClusterReceiver = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#setup-k8s-cluster-receiver"
docLinkResourceDetectionProcessor = "https://signoz.io/docs/infrastructure-monitoring/user-guides/hostmetrics/#configure-the-resourcedetection-processor"
docLinkK8sAttributesProcessor = "https://signoz.io/docs/infrastructure-monitoring/user-guides/k8s-metrics/#3-setup-k8sattributesprocessor-to-enable-kubernetes-metadata"
docLinkHostMetricsReceiver = "https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#configure-the-hostmetrics-receiver"
docLinkKubeletStatsReceiver = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#2-configure-the-kubelet-stats-receiver"
docLinkK8sClusterReceiver = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#1-configure-the-k8s-cluster-receiver"
docLinkResourceDetectionProcessor = "https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#configure-the-processors"
docLinkK8sAttributesProcessor = "https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/#3-enable-kubernetes-metadata"
)
var (

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