Compare commits

...

23 Commits

Author SHA1 Message Date
Ashwin Bhatkal
1a062b3fb1 enh(dashboard-v2): show the variable clear only where it can act
The clear icon appeared on hover of the closed control — an action in a row of
variable pills whose result you cannot see, since the list it clears is not on
screen — and it committed the default immediately in that state. It was also offered
while ALL was selected, where the shared control refuses to empty the selection, so
the icon sat there doing nothing.

It now exists only while the list is open, and not while every option is selected:
unchecking ALL in the list is the way out of that. Clicking it empties the list and
commits nothing; closing fills in whatever the variable should hold — its configured
default, else ALL where it offers one, else the first option.
2026-07-30 22:00:36 +05:30
Ashwin Bhatkal
39bf182213 fix(dashboard-v2): stop a single-select dynamic variable blocking every save
Saving forced `allowAllValue: true` for every dynamic variable while
`allowMultiple` followed the model, so a single-select one went out with ALL set
and no multi-select. The API rejects that combination, and because a save
re-serializes the whole variable list, one such variable failed every edit to the
dashboard — the error even points at that variable's index rather than the one
being edited, which makes it look unrelated.

ALL is a set of values, so it now requires multi-select for every type. Dynamic
variables still always expose it when they are multi-select (V1 parity).
2026-07-30 21:17:38 +05:30
Ashwin Bhatkal
2247968c62 perf(dashboard-v2): settle a variable load without a redundant write
A load committed the same selection twice and refetched everything downstream once
for nothing:

- a CUSTOM variable's options need no request, yet the seed stored ALL as a flag and
  left the post-fetch reconcile to expand it into the concrete array;
- that reconcile runs on a selector's first render, before the seed commits, so its
  auto-fill arrived already satisfied — and the flush wrote it and cascaded anyway.

The seed now resolves against options it already knows (materializing ALL, dropping
values the list no longer offers), and an auto-fill the store already satisfies is
dropped, with the cascade narrowed to the variables that actually moved.

Measured on a dashboard with a text and a custom multi-select variable: a settled
load goes from two store writes and one dependent refetch to one write and none, and
a re-render from a dashboard refetch adds neither.
2026-07-30 21:17:38 +05:30
Ashwin Bhatkal
d2e10d1d9c fix(dashboard-v2): keep ALL readable while a variable's options load
The control renders an ALL selection from the option array, so until those options
arrive there is nothing to show and it falls back to its placeholder: the variable
reads "Select value" next to a spinner, as though nothing were selected. Concrete
values do not have this problem — they render from the selection itself.

Say ALL in that slot while the options are pending. Display only, so it can never
be committed as a value.
2026-07-30 21:17:38 +05:30
Ashwin Bhatkal
90205a376f fix(dashboard-v2): skip the cascade when a variable's value is unchanged
Committing a selection always wrote the store and enqueued the variable's
dependents, so re-picking the value a variable already held refetched every
dependent variable and re-ran every panel query for nothing. Guarded at the single
choke point, order-independently.
2026-07-30 17:37:46 +05:30
Ashwin Bhatkal
59c4f5c7e3 fix(dashboard-v2): apply a variable's configured default reliably
Three separate ways a variable ignored the default its definition carries:

- the reconcile predates the ALL option and filled `options[0]` whenever nothing
  was selected, so an ALL-enabled multi-select never landed on ALL;
- the seed treated any stored entry as a selection, though a selection object is
  truthy even holding '' or [] — an entry persisted empty shadowed the default
  forever, so editing a textbox variable's value in settings never took effect;
- the bar synthesised an empty selection for the render that precedes the seed, and
  the textbox snapshots what it is handed into local state on mount, so a variable
  carrying a value opened with an empty box that filled only on focus + blur.

Precedence is now the same everywhere, through the one resolver: the configured
default first, then ALL where the variable offers it, then the first option. A
stored ALL still wins, even before its option array materializes.

The seed also stops rewriting the store when it resolves to what is already there —
it re-runs on every spec identity change, and an identical write re-rendered every
selection subscriber for nothing.
2026-07-30 17:37:46 +05:30
Gaurav Tewari
7cc728a9c8 feat(llm-attribute-mapping): add Test tab with sample-span runner (#11819)
* feat(llm-attribute-mapping): add Test tab with sample-span runner

Adds the Test tab to the LLM Observability Attribute Mapping page,
replacing the "Coming soon" placeholder. Users paste a sample JSON span
and run it through their configured mappers to preview which target
attributes get populated and which source key matched, before saving.

- Monaco JSON editor with a SigNoz light/dark theme, inline validation
  and a prefilled sample span
- Result diff view labelling attributes added/changed/unchanged/removed
- Payload sends all draft groups; per-group mappers are sent only when
  they differ from the saved snapshot (null -> backend reuses stored
  mappers), so unsaved edits are testable without persisting
- Expose snapshot from useAttributeMappingEditor; add
  useCanManageAttributeMapping hook

Backed by the span mapper test endpoint (#11795).

* chore: remove comments

* fix: minor changes

* chore: review changes

* revert: disable tab changes

* feat: update code

* feat: add reset button as well

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-30 10:53:23 +00:00
Swapnil Nakade
bad3850117 feat: adding cloud storage service for GCP integration (#12341)
* feat: adding gcp memorystore redis service

* refactor: updating dashboard title

* refactor: extending width of uptime gauge panel

* refactor: updating cpu utilization panel

* refactor: updating dashboard panel to use rate function instead of hack

* feat: adding compute engine service

* refactor: updating dashboard panels to use rate aggregation

* fix: correct typo and unit in compute engine dashboard

* refactor: migrating dashboard to v6

* feat: adding gcp gke service

* chore: generating openapi spec

* refactor: updating icon in dashboard JSON

* feat: adding gcp cloud storage service

* refactor: updating dashboard and integration config

* refactor: updating cloud storage icon
2026-07-30 10:09:06 +00:00
Swapnil Nakade
5a8ca9573c feat: adding gcp gke service (#12319)
* feat: adding gcp memorystore redis service

* refactor: updating dashboard title

* refactor: extending width of uptime gauge panel

* refactor: updating cpu utilization panel

* refactor: updating dashboard panel to use rate function instead of hack

* feat: adding compute engine service

* refactor: updating dashboard panels to use rate aggregation

* fix: correct typo and unit in compute engine dashboard

* refactor: migrating dashboard to v6

* feat: adding gcp gke service

* chore: generating openapi spec

* refactor: updating icon in dashboard JSON
2026-07-30 08:48:34 +00:00
Vikrant Gupta
e62bfb4a4c feat(authz): add frontend support for telemetry resources (#12292)
Some checks failed
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat(authz): expose telemetry resources in generated frontend permissions

* feat(authz): add support for telemetry resources on roles page (#12296)

* feat(create-edit-role): add support for telemetry resource

* test(create-edit-role): add tests for telemetry resource

* fix(pr): address comments

* fix(authz): app routing and sidebar fixes when no built-in role (#12307)

* fix(sidebar): render fallback when user preferences fails on sidenav

Otherwise, it fails and don't show any menu that can be pinned

* feat(app-routing): bypass unauthorized when authz is enabled

Those pages should render to allow us to handle authz
directly in each page/component

* fix(private): drop feature flag gate

* fix(authz): backfill managed role transaction groups for meter metrics

* feat(authz): resolve conflicts

* refactor(telemetry-selector): couple fixes after feedback on ux/ui (#12316)

* fix(pr): address more ux feedback

---------

Co-authored-by: Vinicius Lourenço <12551007+H4ad@users.noreply.github.com>
Co-authored-by: Vinícius Lourenço <vinicius@signoz.io>
2026-07-30 07:18:31 +00:00
Aditya Singh
51d5d3ea35 fix: remove navigator clipboard use while using copy btn (#12333)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(copy-button): use focus-independent clipboard copy

navigator.clipboard.writeText requires document focus and rejects with
"Document is not focused" in remote-desktop/VDI/multi-monitor setups.
Copy through react-use's useCopyToClipboard (execCommand) via a colocated
useCopyButton hook that also owns the transient "copied" state.

Fixes SIGNOZ-UI-5J4

* refactor(legend): reuse CopyButton for the copy action

Replace the duplicated inline copy button with the shared CopyButton.
Each button now owns its own copied state, so the per-item id tracking
is no longer needed.

* chore(hooks): remove unused useCopyToClipboard hook

No longer imported after CopyButton and the legend moved to
useCopyButton.
2026-07-29 18:51:19 +00:00
Gaurav Tewari
7eb3f7df32 fix(logs): open log details in table (Column) format when activeId is set (#12269)
* fix(logs): open log details in table (Column) format when activeId is set

Opening a log link (?activeLogId=...) and switching to Column/table format
left the highlighted row un-openable — clicking it did nothing.

Root cause: the shared TanStackTable decided open vs. close internally from
`isRowActive` + `onRowDeactivate`. LogsExplorerList had overloaded `isRowActive`
with `|| log.id === activeLogId`, so a URL-highlighted-but-closed row looked
"active" and the first click ran the deactivate (close) path instead of opening.

Fix (PR 1 of 2): move click routing to the consumer.
- Remove `onRowDeactivate` from TanStackTable; it took no args and only fired
  when re-clicking the same active row (clicking A→B never deactivated A).
- `onRowClick` now receives a third `{ isActive }` context arg (additive; the
  ~14 existing consumers are unaffected).
- LogsExplorerList and LiveLogsList own the toggle:
  `isActive ? handleCloseLogDetail() : handleSetActiveLog(log)`, and
  `isRowActive` tracks only `activeLog?.id`.

The linked-row visual highlight (`isHighlighted`) is deferred to PR 2 (blocked
on a design decision for the highlight color); see
frontend/docs/tdd-tanstack-row-click-routing.md.

* feat(logs): highlight the URL-linked (activeLogId) row in table format (#12271)

Restores the linked-row highlight that the TanStack migration (#10946)
dropped from the table view, without coupling it to click routing.

Uses the table's existing `getRowClassName` hook (styling-only) — no new
shared-table prop:
- LogsExplorerList / LiveLogsList set getRowClassName to 'logs-linked-row'
  when `log.id === activeLogId`.
- A global, lowest-specificity rule paints `.logs-linked-row td` using the
  per-row `--row-active-bg` var (already set via getRowStyle). The table's own
  :hover / .tableRowActive cell rules are higher-specificity !important, so
  active/hover correctly win when a linked row is also open or hovered.

`isRowActive` stays `activeLog?.id` only, so the highlighted row still opens on
first click (relies on the routing fix in the base PR).

Color reuses --row-active-bg as a placeholder; a distinct "linked" token is a
follow-up pending design.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>

* chore: remove comments

* chore: remove extra comments

* feat: add e2e for flows

* chore: add test id

* chore: chore: remove comment

* chore: remove comment

* chore: remove comment

* chore: add comment

* chore: remove comment

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-07-29 18:24:30 +00:00
Aditya Singh
5eb3b5e3e0 test(query-builder): make suggestion-fetch debounce overridable to deflake CodeMirror spec (#12336) 2026-07-29 18:22:27 +00:00
Vinicius Lourenço
b88ee12cd5 chore(docs): add authz guide (#12317)
* chore(docs): add authz guide

* chore(assets): clarify what withAuthZContent means
2026-07-29 17:18:15 +00:00
Pandey
6f3dd0b7ad fix(querybuilder): give each clickhouse sql refusal its own error code (#12339)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(querybuilder): give each clickhouse sql refusal its own error code

Every refusal used CodeInvalidInput, so the only way to tell why a statement was
refused was to match on the message. Counting the failures on a live deployment
meant regexing the parser's text out of the log, and grouping by cause meant
knowing which of five different messages belonged to the same underlying gap.

One code per reason instead, which arrives as exception.code on the warning that
LogIfStatementIsNotValid writes, so the failures can be grouped and alerted on
directly.

Pin the expected code on each failing case, so the mapping is checked rather
than assumed.

* chore(querybuilder): drop the comment above the error codes

The names say it.

* fix(querybuilder): separate the parser panic code from the parse failure

A panic is a parser defect worth alerting on; a parse failure is a grammar gap
that shows up in ordinary traffic. Sharing one code lumps the two together.
2026-07-29 15:36:10 +00:00
Pandey
d32d93cd39 chore(deps): bump clickhouse-sql-parser to v0.5.3 (#12331)
* chore(deps): bump clickhouse-sql-parser to v0.5.3

v0.5.3 stops lexing a signed number after a closing bracket as one literal, so
`(now()-1)` and `arr[1]-1` parse where they used to fail. That was the third of
the three gaps the validator trips over on real dashboard SQL, and the only one
where the statement had to be rewritten to be accepted.

Move the two queries it covers out of the failing set, which is what that test
exists to prompt. On the production corpus the rejection rate goes from 6.1% to
5.6% of v5 query shapes; the remaining two gaps, `interval` used as a column name
and the standard trim(BOTH x FROM y) syntax, are still open upstream.

* chore(querybuilder): link the upstream issue on the signed literal cases
2026-07-29 15:33:00 +00:00
Tushar Vats
e9a931788c chore(statementbuilder): log key adjustment actions at debug level (#12337)
These fired at info on every query build across the audit, logs and traces
builders. The behavior is settled, so drop them to debug and remove the
TODOs that asked for exactly this.
2026-07-29 14:37:09 +00:00
Ashwin Bhatkal
e51c464417 fix(frontend): alias lodash to lodash-es to survive a page-level AMD loader (#12332)
@grafana/data's ESM build imports bare CJS `lodash` in ~28 modules.
lodash's UMD footer checks for an AMD loader before assigning
module.exports, so when a third-party script has already defined
window.define.amd, lodash takes that branch and never exports. rolldown
can't statically detect lodash's dynamic named exports, so those imports
compile to runtime namespace reads against an empty object and every
method is undefined.

lodash-es is the same version, real ESM, and tree-shakes.
2026-07-29 14:03:32 +00:00
Tushar Vats
04637bd960 test(querier): assert rendered SQL in skip_resource_fingerprint tests (#12334)
Identical rows can't distinguish the optimized instance from the
fingerprint baseline, so the tests now also diff the rendered ClickHouse
statement via /query_range/preview: the baseline must join the fingerprint
CTE and the optimized instance must not.

The assertion is pinned to the "resource_fingerprint GLOBAL IN (SELECT
fingerprint FROM __resource_filter)" clause, a literal in the statement
builder. The resource condition the fallback path renders on the main
table is deliberately not asserted: its column form follows the resource
JSON evolution boundary, rendering as a multiIf over resources_string
while the query window straddles release_time and collapsing to
resource.`key`::String once the window sits past it.

Also fixes the fixture env prefix. skip_resource_fingerprint moved out of
the querier config namespace into its own, so
SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_* was being ignored and both
instances ran with the optimization disabled.
2026-07-29 12:51:03 +00:00
Swapnil Nakade
0703fbf961 feat: adding compute engine service in GCP integration (#12166)
* feat: adding gcp memorystore redis service

* refactor: updating dashboard title

* refactor: extending width of uptime gauge panel

* refactor: updating cpu utilization panel

* refactor: updating dashboard panel to use rate function instead of hack

* feat: adding compute engine service

* refactor: updating dashboard panels to use rate aggregation

* fix: correct typo and unit in compute engine dashboard

* refactor: migrating dashboard to v6
2026-07-29 11:28:58 +00:00
Tushar Vats
4e45620b72 refactor(statementbuilder): drop Builders bundle; fold factories into statement_builder.go (#12330)
Remove the statementbuilder.Builders aggregate. The querier chain (querier.New,
signozquerier.NewFactory, NewQuerierProviderFactories) now takes the six per-signal
statement builders individually, and signoz.go::newQueryStack returns them directly
via a plain multi-return. The statementbuilder package is now config-only.

Remove each <signal>statementbuilder/new.go and fold its NewFactory into that
package's statement_builder.go (traces' NewOperatorFactory into
trace_operator_statement_builder.go), giving the layout: struct -> NewFactory ->
New<X>QueryStatementBuilder.
2026-07-29 10:46:55 +00:00
Naman Verma
a9506f1354 fix: add meter source where needed during dashboard migration (#12322)
* fix: add meter source during migration

* chore: move source field handling to wrapinv5envelope

* fix: add migration to backfill source for migrated dashboards

* chore: shorten comments
2026-07-29 10:39:41 +00:00
Tushar Vats
e2e7caf1ca refactor(querier): 3-layer per-signal query architecture (#12304)
Split the five telemetry<signal> packages, which mixed three concerns, into
three per-signal layers with a cycle-free dependency direction:

- telemetryschema/<signal>telemetryschema — primitives (const + table
  selection, field_mapper, condition_builder, trace helpers); leaf layer.
- statementbuilder/<signal>statementbuilder — SQL generation. The parent
  statementbuilder package is contract-only (the Builders bundle + Config);
  each sub-package exposes a factory.ProviderFactory[..., statementbuilder.Config]
  whose New internalizes FieldMapper/ConditionBuilder/AggExprRewriter and reads
  SkipResourceFingerprint. Traces exposes two factories (query + operator).
  telemetryresourcefilter moves here as statementbuilder/resourcefilter.
- telemetrymetadata — key/value resolution; NewTelemetryMetaStore collapses
  from 24 args to (settings, telemetrystore, flagger), sourcing table names
  from the schema constants.

Centralize query-stack assembly in signoz.go via newQueryStack: build the
single metadata store, run each per-signal statement-builder factory, assemble
the statementbuilder.Builders bundle, and build the bucket cache — once. This
is the only place that imports the concrete sub-packages (so the edge runs
subs -> parent, cycle-free), and it removes the duplicate metadata store that
signozquerier used to build, leaving signozquerier a thin
querier.New(*statementbuilder.Builders) adapter.

Also:
- statementbuilder.Config owns SkipResourceFingerprint (moved off
  querier.Config). YAML key moves querier.skip_resource_fingerprint ->
  statementbuilder.skip_resource_fingerprint.
- Querier interface moves into querier.go (interfaces.go removed); BucketCache
  -> bucket_cache.go, Handler -> api.go.
- Add pkg/querier/queriertest.MockQuerier.
2026-07-29 09:24:08 +00:00
219 changed files with 10640 additions and 1680 deletions

View File

@@ -93,9 +93,13 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
}
allowedTypes := map[string]bool{}

View File

@@ -2,9 +2,9 @@ package main
import (
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrytraces"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
"github.com/SigNoz/signoz/pkg/types/zeustypes"
)
@@ -25,8 +25,8 @@ var meterConfigs = []metercollector.Config{
Name: zeustypes.MeterLogSize,
Unit: zeustypes.MeterUnitBytes,
Aggregation: zeustypes.MeterAggregationSum,
DBName: telemetrylogs.DBName,
TableName: telemetrylogs.LogsV2LocalTableName,
DBName: logstelemetryschema.DBName,
TableName: logstelemetryschema.LogsV2LocalTableName,
DefaultRetentionDays: retentiontypes.DefaultLogsRetentionDays,
},
},
@@ -36,8 +36,8 @@ var meterConfigs = []metercollector.Config{
Name: zeustypes.MeterSpanSize,
Unit: zeustypes.MeterUnitBytes,
Aggregation: zeustypes.MeterAggregationSum,
DBName: telemetrytraces.DBName,
TableName: telemetrytraces.SpanIndexV3LocalTableName,
DBName: tracestelemetryschema.DBName,
TableName: tracestelemetryschema.SpanIndexV3LocalTableName,
DefaultRetentionDays: retentiontypes.DefaultTracesRetentionDays,
},
},
@@ -47,8 +47,8 @@ var meterConfigs = []metercollector.Config{
Name: zeustypes.MeterDatapointCount,
Unit: zeustypes.MeterUnitCount,
Aggregation: zeustypes.MeterAggregationSum,
DBName: telemetrymetrics.DBName,
TableName: telemetrymetrics.SamplesV4LocalTableName,
DBName: metricstelemetryschema.DBName,
TableName: metricstelemetryschema.SamplesV4LocalTableName,
DefaultRetentionDays: retentiontypes.DefaultMetricsRetentionDays,
},
},

View File

@@ -1496,6 +1496,9 @@ components:
- redis
- cloudsql_postgres
- memorystore_redis
- computeengine
- gke
- cloudstorage
type: string
CloudintegrationtypesServiceMetadata:
properties:

View File

@@ -16,7 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/metercollector"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/licensetypes"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
@@ -153,7 +153,7 @@ func (provider *Provider) Collect(
func buildOriginQuery(meterName string) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("toInt64(ifNull(min(unix_milli), 0))")
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
sb.Where(sb.Equal("metric_name", meterName))
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
@@ -171,7 +171,7 @@ func buildQuery(meterName string, segment *retentiontypes.RetentionPolicySegment
sb := sqlbuilder.NewSelectBuilder()
sb.Select(selects...)
sb.From(telemetrymeter.DBName + "." + telemetrymeter.SamplesTableName)
sb.From(metertelemetryschema.DBName + "." + metertelemetryschema.SamplesTableName)
sb.Where(
sb.Equal("metric_name", meterName),
sb.GTE("unix_milli", segment.StartMs),

View File

@@ -8,16 +8,16 @@ import (
sqlbuilder "github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricreductionruletypes"
)
var (
reductionRulesTable = telemetrymetrics.DBName + "." + telemetrymetrics.ReductionRulesTableName
metadataTable = telemetrymetrics.DBName + "." + telemetrymetrics.AttributesMetadataTableName
bufferSeriesTable = telemetrymetrics.DBName + "." + telemetrymetrics.TimeseriesV4BufferTableName
reductionRulesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.ReductionRulesTableName
metadataTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.AttributesMetadataTableName
bufferSeriesTable = metricstelemetryschema.DBName + "." + metricstelemetryschema.TimeseriesV4BufferTableName
)
const timeSeriesBucketMilli = int64(time.Hour / time.Millisecond)
@@ -192,7 +192,7 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
sb := sqlbuilder.NewSelectBuilder()
sb.Select("metric_name", "uniq(fingerprint)")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
conds := []string{
sb.In("metric_name", names...),
sb.GE("unix_milli", startMs),
@@ -229,8 +229,8 @@ func (c *clickhouse) ingestedSeriesCount(ctx context.Context, metricNames []stri
// reduced sample tables.
func (c *clickhouse) reducedSeriesCount(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[string]uint64, error) {
out := make(map[string]uint64, len(metricNames))
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
counts, err := c.reducedSeriesCountForTable(ctx, telemetrymetrics.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
counts, err := c.reducedSeriesCountForTable(ctx, metricstelemetryschema.DBName+"."+table, metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
@@ -300,9 +300,9 @@ func (c *clickhouse) RankByVolume(ctx context.Context, metricNames []string, eff
direction = "DESC"
}
ingestedTable := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName
reducedLast := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedLastTableName
reducedSum := telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4ReducedSumTableName
ingestedTable := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName
reducedLast := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedLastTableName
reducedSum := metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4ReducedSumTableName
sb := sqlbuilder.NewSelectBuilder()
sb.Select("base.metric_name AS metric_name", "ifNull(i.cnt, 0) AS ingested", "ifNull(d.cnt, 0) AS reduced")
@@ -352,16 +352,16 @@ func (c *clickhouse) SampleVolumeByMetric(ctx context.Context, metricNames []str
}
ctx = c.withThreads(ctx)
ingested, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
ingested, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4BufferTableName, "count()", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
last, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
last, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedLastTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
sum, err := c.countSamplesByMetric(ctx, telemetrymetrics.DBName+"."+telemetrymetrics.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
sum, err := c.countSamplesByMetric(ctx, metricstelemetryschema.DBName+"."+metricstelemetryschema.SamplesV4ReducedSumTableName, "uniq(reduced_fingerprint, unix_milli)", metricNames, effectiveFrom, startMs, endMs)
if err != nil {
return nil, err
}
@@ -419,7 +419,7 @@ func (c *clickhouse) TotalVolume(ctx context.Context, startMs, endMs int64) (uin
sb := sqlbuilder.NewSelectBuilder()
sb.Select("uniq(fingerprint)", "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
@@ -470,7 +470,7 @@ func (c *clickhouse) SampleTimeseries(ctx context.Context, ruledMetrics []string
func (c *clickhouse) totalSamplesByBucket(ctx context.Context, startMs, endMs int64) (map[int64]uint64, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
sb.Where(sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs))
sb.GroupBy("bucket")
@@ -485,7 +485,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "count()")
sb.From(telemetrymetrics.DBName + "." + telemetrymetrics.SamplesV4BufferTableName)
sb.From(metricstelemetryschema.DBName + "." + metricstelemetryschema.SamplesV4BufferTableName)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))
@@ -499,7 +499,7 @@ func (c *clickhouse) ruledIngestedSamplesByBucket(ctx context.Context, metricNam
// reduced 60s rows are versioned by computed_at, so count distinct buckets.
func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNames []string, effectiveFrom map[string]int64, startMs, endMs int64) (map[int64]uint64, error) {
out := make(map[int64]uint64)
for _, table := range []string{telemetrymetrics.SamplesV4ReducedLastTableName, telemetrymetrics.SamplesV4ReducedSumTableName} {
for _, table := range []string{metricstelemetryschema.SamplesV4ReducedLastTableName, metricstelemetryschema.SamplesV4ReducedSumTableName} {
names := make([]any, len(metricNames))
for i, name := range metricNames {
names[i] = name
@@ -507,7 +507,7 @@ func (c *clickhouse) ruledRetainedSamplesByBucket(ctx context.Context, metricNam
sb := sqlbuilder.NewSelectBuilder()
sb.Select(sampleBucketExpr, "uniq(reduced_fingerprint, unix_milli)")
sb.From(telemetrymetrics.DBName + "." + table)
sb.From(metricstelemetryschema.DBName + "." + table)
conds := []string{sb.In("metric_name", names...), sb.GE("unix_milli", startMs), sb.LT("unix_milli", endMs)}
if len(effectiveFrom) > 0 {
conds = append(conds, strictEffectiveFrom(sb, metricNames, effectiveFrom))

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 139 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,136 @@
# AuthZ Guide
How to structure a page so it works with the permission system.
We are migrating from the `ADMIN | EDITOR | VIEWER` roles to per-action checks. Instead of granting `VIEWER` and
exposing everything, a user can now be granted access to a single resource, eg: `Logs` only.
- [Prerequisites](#prerequisites)
- [Core rules](#core-rules)
- [Page patterns](#page-patterns)
- [What "blocked" means](#what-blocked-means)
- [Migration checklist](#migration-checklist)
- [Testing](#testing)
## Prerequisites
Check whether the resource your page represents is supported: see
[permissions.type.ts](../src/lib/authz/hooks/useAuthZ/permissions.type.ts) for the current resources and their allowed
verbs.
**If the resource is not listed there, skip authz for now**. The backend does not enforce it yet, so any frontend
check would be decorative. Revisit once the resource is generated into that file (it is auto-generated from the
backend).
## Core rules
These hold for every page. The per-pattern sections below only add to them.
1. **A page is always reachable, regardless of permission.** The intended action must be known before permission can
be checked, so the route renders first and individual pieces are gated.
2. **`update` requires both `read` and `update`.** A user who can write but cannot read the current value must not get
an edit affordance.
3. **`delete` is independent of `read`.** Delete controls stay visible for a user who holds `delete` but not `read`.
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
detail route).
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
them, not the surrounding content.
## Page patterns
### List page
![Visual rules for structuring a list page](./assets/list-example.svg)
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
- Title, description, search filters and action buttons stay visible.
- Filters and any control that drives the table are non-interactive.
- The create button stays enabled if the user holds `create`.
### Edit page
![Visual rules for structuring an edit page](./assets/edit-example.svg)
Without `read`:
- Block the content with `withAuthZContent`, not the whole route.
- Keep delete visible (rule 3).
- Keep update blocked (rule 2).
### Drawer
![Visual rules for structuring a drawer](./assets/drawer-example.svg)
Without `read`:
- Block the drawer body, not the drawer itself.
- Prefer routing that carries the resource ID in the URL, so delete stays reachable without `read`.
- Keep update blocked (rule 2).
- Watch for sub-resources the user may still be allowed to act on (rule 6).
### Create
Without `create`:
| Entry point | Gate with |
| --------------------- | ------------------------------------------------------------- |
| Button | `AuthZButton` |
| Dedicated create page | `withAuthZPage` |
| Create drawer opened directly (deep link) | `withAuthZContent` on the body + `AuthZButton` on footer actions |
### Delete
Gate the control with `AuthZButton`, or `AuthZTooltip` for a non-button trigger such as an icon or menu item.
### Quick filters
![Visual rules for structuring a page with quick filters](./assets/quick-filters-example.svg)
## What "blocked" means
Blocking is always a visible denial, never a silent removal. Use the components in
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
denial message and the loading state.
| Scope | Component | Denied state |
| --------------- | ----------------------------------------- | ------------------------------------------- |
| Button | `AuthZButton` | Disabled + tooltip |
| Any element | `AuthZTooltip` | Disabled child + tooltip |
| Section | `withAuthZContent` / `AuthZGuardContent` | Inline `PermissionDeniedCallout` |
| Page / route | `withAuthZPage` / `AuthZGuardPage` | `PermissionDeniedFullPage` |
| Custom fallback | `withAuthZ` / `AuthZGuard` | Whatever you pass as `fallback` |
Prefer the HOC (`withAuthZ*`); reach for the JSX guard (`AuthZGuard*`) when the gate depends on conditional rendering
and an HOC cannot express it. The components README has the full decision tree and how to build the `checks` array.
## Migration checklist
Use the existing components. Create a new one only if none fit.
- [ ] Can I apply a `withAuthZ*` variant directly, or must I extract the content into a component first?
- If extraction is needed, declare the new content component in the same file to keep the diff small, then move it
to its own file in a follow-up commit or PR.
- [ ] Is the layout structured to respect the [core rules](#core-rules)?
- If not, raise it in the frontend Slack channel before reshaping the page.
- [ ] Am I gating a shared component rather than a page or section?
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
the user cannot read them.
## Testing
### Devtool
Press `Cmd + K` (`Ctrl + K` on Windows/Linux) to open the shortcuts, search for `AuthZ`, and pick the first
result. The devtool simulates granted and denied permissions across the UI.
Available only in local development, it is stripped from production builds.
### Unit tests
[`lib/authz/utils/README.md`](../src/lib/authz/utils/README.md) covers the `*.authz.test.tsx` naming convention, the
MSW handlers (`setupAuthzAdmin`, `setupAuthzDenyAll`, `setupAuthzDeny`, `setupAuthzAllow`,
`setupAuthzGrantByPrefix`), and how to test the loading state.

View File

@@ -14,7 +14,10 @@ import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { USER_ROLES } from 'types/roles';
import { routePermission } from 'utils/permission';
import {
routePermission,
routeWithInitialAuthZSupport,
} from 'utils/permission';
import routes, {
LIST_LICENSES,
@@ -42,7 +45,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -226,7 +228,16 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
if (isPrivate) {
if (isLoggedInState) {
const route = routePermission[key];
if (route && route.find((e) => e === user.role) === undefined) {
const hasInitialAuthZSupport = Object.hasOwn(
routeWithInitialAuthZSupport,
key,
);
if (
route &&
route.find((e) => e === user.role) === undefined &&
hasInitialAuthZSupport === false
) {
return <Redirect to={ROUTES.UN_AUTHORIZED} />;
}
} else {

View File

@@ -17,6 +17,7 @@ import {
} from 'types/api/licensesV3/getActive';
import { OrgPreference } from 'types/api/preferences/preference';
import { ROLES, USER_ROLES } from 'types/roles';
import { routeWithInitialAuthZSupport } from 'utils/permission';
import PrivateRoute from '../Private';
@@ -178,6 +179,7 @@ function createMockAppContext(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: null,
activeLicenseFetchError: null,
hostsFetchError: null,
@@ -198,24 +200,39 @@ function createMockAppContext(
};
}
// Roles that no authz-aware route grants through the legacy routePermission table
const DENIED_ROLES: ROLES[] = [
USER_ROLES.ANONYMOUS as ROLES,
USER_ROLES.AUTHOR as ROLES,
];
const PERMITTED_ROLES: ROLES[] = [
USER_ROLES.ADMIN as ROLES,
USER_ROLES.EDITOR as ROLES,
USER_ROLES.VIEWER as ROLES,
];
interface AuthzRouteCase {
path: string;
deniedRoles: ROLES[];
}
interface RenderPrivateRouteOptions {
initialRoute?: string;
appContext?: Partial<IAppContext>;
isCloudUser?: boolean;
}
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
const {
initialRoute = ROUTES.HOME,
appContext = {},
isCloudUser = true,
} = options;
function buildPrivateRouteTree(
options: RenderPrivateRouteOptions,
): ReactElement {
const { initialRoute = ROUTES.HOME, appContext = {} } = options;
mockIsCloudUser = isCloudUser;
const contextValue = createMockAppContext({
...appContext,
});
const contextValue = createMockAppContext(appContext);
render(
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<AppContext.Provider value={contextValue}>
@@ -229,10 +246,15 @@ function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
</PrivateRoute>
</AppContext.Provider>
</MemoryRouter>
</QueryClientProvider>,
</QueryClientProvider>
);
}
function renderPrivateRoute(options: RenderPrivateRouteOptions = {}): void {
mockIsCloudUser = options.isCloudUser ?? true;
render(buildPrivateRouteTree(options));
}
// Generic assertion helpers for navigation behavior
// Using location-based assertions since Private.tsx now uses Redirect component
@@ -1432,6 +1454,25 @@ describe('PrivateRoute', () => {
assertStaysOnRoute(ROUTES.GET_STARTED_WITH_CLOUD);
});
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
'should render the unauthorized page for a %s instead of bouncing off it',
(role) => {
// routePermission.UN_AUTHORIZED must grant every role: a role denied here
// is redirected to /un-authorized and then redirected away from it again,
// which loops until React bails out with a blank screen.
renderPrivateRoute({
initialRoute: ROUTES.UN_AUTHORIZED,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
assertStaysOnRoute(ROUTES.UN_AUTHORIZED);
assertRendersChildren();
},
);
});
describe('Edge Cases', () => {
@@ -1477,6 +1518,174 @@ describe('PrivateRoute', () => {
});
});
describe('AuthZ Support (routeWithInitialAuthZSupport)', () => {
// Routes in routeWithInitialAuthZSupport always bypass legacy role check
const AUTHZ_ROUTE_CASES: Record<
keyof typeof routeWithInitialAuthZSupport,
AuthzRouteCase
> = {
// Everything under /settings resolves to the non-exact SETTINGS route
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
ROLES_SETTINGS: { path: ROUTES.ROLES_SETTINGS, deniedRoles: DENIED_ROLES },
ROLE_CREATE: { path: ROUTES.ROLE_CREATE, deniedRoles: DENIED_ROLES },
ROLE_DETAILS: {
path: ROUTES.ROLE_DETAILS.replace(':roleId', 'role-id-1'),
deniedRoles: DENIED_ROLES,
},
ROLE_EDIT: {
path: ROUTES.ROLE_EDIT.replace(':roleId', 'role-id-1'),
deniedRoles: DENIED_ROLES,
},
SERVICE_ACCOUNTS_SETTINGS: {
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
deniedRoles: DENIED_ROLES,
},
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
TRACE_DETAIL: {
path: ROUTES.TRACE_DETAIL.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
TRACE_DETAIL_OLD: {
path: ROUTES.TRACE_DETAIL_OLD.replace(':id', 'trace-id-1'),
deniedRoles: DENIED_ROLES,
},
// LOGS and LOGS_EXPLORER share a path - matchPath resolves it to whichever
// route definition comes last, and both keys are authz-aware either way.
LOGS: { path: ROUTES.LOGS, deniedRoles: DENIED_ROLES },
LOGS_EXPLORER: { path: ROUTES.LOGS_EXPLORER, deniedRoles: DENIED_ROLES },
LIVE_LOGS: { path: ROUTES.LIVE_LOGS, deniedRoles: DENIED_ROLES },
OLD_LOGS_EXPLORER: {
path: ROUTES.OLD_LOGS_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER: {
path: ROUTES.METRICS_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER_EXPLORER: {
path: ROUTES.METRICS_EXPLORER_EXPLORER,
deniedRoles: DENIED_ROLES,
},
METRICS_EXPLORER_VOLUME_CONTROL: {
path: ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
deniedRoles: DENIED_ROLES,
},
METER: { path: ROUTES.METER, deniedRoles: DENIED_ROLES },
METER_EXPLORER: { path: ROUTES.METER_EXPLORER, deniedRoles: DENIED_ROLES },
// SUPPORT already grants ANONYMOUS in routePermission, so only AUTHOR
// exercises the redirect branch here.
SUPPORT: {
path: ROUTES.SUPPORT,
deniedRoles: [USER_ROLES.AUTHOR as ROLES],
},
};
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
AUTHZ_ROUTE_CASES,
).flatMap(([name, { path, deniedRoles }]) =>
deniedRoles.map((role): [string, string, ROLES] => [name, path, role]),
);
// Routes with no authz check - legacy role check still applies
const NON_AUTHZ_ROUTE_CASES: [string, string, ROLES][] = [
['ALERTS_NEW', ROUTES.ALERTS_NEW, USER_ROLES.VIEWER as ROLES],
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
[
'METRICS_EXPLORER_VIEWS',
ROUTES.METRICS_EXPLORER_VIEWS,
USER_ROLES.ANONYMOUS as ROLES,
],
];
it.each(authzRouteRolePairs)(
'should not redirect %s (%s) for role %s - authz routes bypass legacy role check',
(_name, path, role) => {
// Routes in routeWithInitialAuthZSupport always bypass the legacy role check.
// Authorization is handled by downstream components via fine-grained authz.
renderPrivateRoute({
initialRoute: path,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
assertStaysOnRoute(path);
assertRendersChildren();
},
);
it.each([...PERMITTED_ROLES, ...DENIED_ROLES])(
'should not redirect a %s from an authz-aware route',
(role) => {
// All roles pass through - authorization handled downstream
renderPrivateRoute({
initialRoute: ROUTES.LOGS_EXPLORER,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
assertStaysOnRoute(ROUTES.LOGS_EXPLORER);
assertRendersChildren();
},
);
it.each(NON_AUTHZ_ROUTE_CASES)(
'should redirect %s (%s) for role %s - non-authz routes use legacy role check',
async (_name, path, role) => {
// Routes NOT in routeWithInitialAuthZSupport still use legacy role check
renderPrivateRoute({
initialRoute: path,
appContext: {
isLoggedIn: true,
user: createMockUser({ role }),
},
});
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
},
);
it.each(authzRouteRolePairs)(
'should still redirect unauthenticated users away from %s (%s) with role %s',
async (_name, path, role) => {
// The authz bypass only relaxes the role check, never the login check.
renderPrivateRoute({
initialRoute: path,
appContext: {
isLoggedIn: false,
user: createMockUser({ role }),
},
});
await assertRedirectsTo(ROUTES.LOGIN);
},
);
it('should still redirect to workspace locked from an authz-aware route', async () => {
// Workspace guards run before the role check and must not be bypassed.
renderPrivateRoute({
initialRoute: ROUTES.LOGS_EXPLORER,
appContext: {
isLoggedIn: true,
isFetchingActiveLicense: false,
activeLicense: createMockLicense({ platform: LicensePlatform.CLOUD }),
trialInfo: createMockTrialInfo({ workSpaceBlock: true }),
user: createMockUser({ role: USER_ROLES.VIEWER as ROLES }),
},
isCloudUser: true,
});
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
});
});
describe('Old channel route redirects', () => {
it.each([
['/settings/channels', '/alerts', 'tab=Channels'],

View File

@@ -2815,6 +2815,9 @@ export enum CloudintegrationtypesServiceIDDTO {
redis = 'redis',
cloudsql_postgres = 'cloudsql_postgres',
memorystore_redis = 'memorystore_redis',
computeengine = 'computeengine',
gke = 'gke',
cloudstorage = 'cloudstorage',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="800" viewBox="-19.2 -28.483 166.401 170.898"><g transform="translate(0 -7.034)"><linearGradient id="a" x1="64" x2="64" y1="7.034" y2="120.789" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4387fd"/><stop offset="1" stop-color="#4683ea"/></linearGradient><path fill="url(#a)" d="M27.79 115.217 1.54 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.96-5.75h52.5a11.5 11.5 0 0 1 9.959 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.959 5.75h-52.5a11.5 11.5 0 0 1-9.96-5.75z"/></g><g transform="translate(0 -7.034)"><defs><path id="b" d="M27.791 115.217 1.541 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.959-5.75h52.5a11.5 11.5 0 0 1 9.96 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.96 5.75h-52.5a11.5 11.5 0 0 1-9.959-5.75z"/></defs><clipPath id="c"><use xlink:href="#b" width="100%" height="100%" overflow="visible"/></clipPath><path d="m49.313 53.875-7.01 6.99 5.957 5.958-5.898 10.476 44.635 44.636 10.816.002L118.936 84 85.489 50.55z" clip-path="url(#c)" opacity=".07"/></g><path fill="#fff" d="M84.7 43.236H43.264c-.667 0-1.212.546-1.212 1.214v8.566c0 .666.546 1.212 1.212 1.212H84.7c.667 0 1.213-.546 1.213-1.212v-8.568c0-.666-.545-1.213-1.212-1.213m-6.416 7.976a2.484 2.484 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477c1.37 0 2.48 1.103 2.48 2.477a2.48 2.48 0 0 1-2.48 2.48m6.415 8.491-41.436.002c-.667 0-1.212.546-1.212 1.214v8.565c0 .666.546 1.213 1.212 1.213H84.7c.667 0 1.213-.547 1.213-1.213v-8.567c0-.666-.545-1.214-1.212-1.214m-6.416 7.976a2.483 2.483 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477 2.48 2.48 0 1 1 0 4.956"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M19.73 6.56a1.73 1.73 0 0 0-1.68 1.68 1.83 1.83 0 0 0 .89 1.48v6.9l-5.16 3.06.8 1.28 5.55-3.25a.84.84 0 0 0 .4-.69v-7.3a1.64 1.64 0 0 0 .89-1.48 1.61 1.61 0 0 0-1.69-1.68" style="fill:#aecbfa"/><path d="m18 5.48-5.61-3.16a1.18 1.18 0 0 0-.79 0L5.25 6a1.72 1.72 0 0 0-2.68 1.35A1.73 1.73 0 0 0 4.26 9a1.73 1.73 0 0 0 1.68-1.65L12 3.9l5.15 3ZM11.2 18.5a1.57 1.57 0 0 0-.89.29l-5.16-3V9.92H3.56v6.31a.84.84 0 0 0 .4.69L9.52 20v.09a1.69 1.69 0 0 0 3.37 0 1.65 1.65 0 0 0-1.69-1.59" data-name="Path" style="fill:#aecbfa"/><path d="M16.96 8.63 12.1 5.78 7.13 8.63l4.97 2.77z" data-name="Path" style="fill:#669df6"/><path d="M12.1 12.38 6.84 9.32v2.47l5.26 2.96zM12.1 15.73l-5.26-3.05v2.07l5.26 3.06z" style="fill:#669df6"/><path d="M12.09 12.38v2.37l5.26-3.06V9.33Zm4.32-.94a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76M12.09 15.73v2.07l5.26-3.05v-2.07Zm4.32-1.07a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76" style="fill:#4285f4"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 988 B

After

Width:  |  Height:  |  Size: 941 B

View File

@@ -438,7 +438,11 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip

View File

@@ -49,7 +49,11 @@ import { unquote } from 'utils/stringUtils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type { SignalType } from 'types/api/v5/queryRange';
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
import {
queryExamples,
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -353,7 +357,7 @@ function QuerySearch({
);
const debouncedFetchKeySuggestions = useMemo(
() => debounce(fetchKeySuggestions, 300),
() => debounce(fetchKeySuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
[fetchKeySuggestions],
);
@@ -584,7 +588,7 @@ function QuerySearch({
);
const debouncedFetchValueSuggestions = useMemo(
() => debounce(fetchValueSuggestions, 300),
() => debounce(fetchValueSuggestions, SUGGESTION_FETCH_DEBOUNCE_MS),
[fetchValueSuggestions],
);

View File

@@ -1,6 +1,8 @@
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
export const SUGGESTION_FETCH_DEBOUNCE_MS = 300;
// TODO: move to using TelemetrytypesFieldContextDTO when we migrate getKeySuggestions API (Part of https://github.com/SigNoz/engineering-pod/issues/5289)
export const FIELD_CONTEXTS = [
'attribute',

View File

@@ -23,6 +23,13 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
}),
}));
// Shrink the suggestion-fetch debounce (300ms in prod) so these integration
// tests aren't paced by it; coalescing semantics stay intact.
jest.mock('../QuerySearch/constants', () => ({
...jest.requireActual('../QuerySearch/constants'),
SUGGESTION_FETCH_DEBOUNCE_MS: 30,
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
const handleRunQuery = jest.fn();
return {

View File

@@ -88,6 +88,7 @@ function TanStackCustomTableRow<TData, TItemKey = string>({
{...props}
className={rowClassName}
style={rowStyle}
data-testid={context?.getRowTestId?.(rowData)}
onMouseEnter={handleMouseEnter}
onMouseLeave={clearHovered}
>
@@ -154,6 +155,12 @@ function areTableRowPropsEqual<TData, TItemKey = string>(
return false;
}
const prevTestId = prev.context?.getRowTestId?.(prevData) ?? '';
const nextTestId = next.context?.getRowTestId?.(nextData) ?? '';
if (prevTestId !== nextTestId) {
return false;
}
const prevStyle = prev.context?.getRowStyle?.(prevData);
const nextStyle = next.context?.getRowStyle?.(nextData);
if (prevStyle !== nextStyle) {

View File

@@ -33,7 +33,6 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
// Stable references via destructuring, keep them as is
const onRowClick = context?.onRowClick;
const onRowClickNewTab = context?.onRowClickNewTab;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const getRowKeyData = context?.getRowKeyData;
const rowIndex = row.index;
@@ -52,21 +51,9 @@ function TanStackRowCellsInner<TData, TItemKey = string>({
}
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
onRowClick?.(rowData, itemKey, { isActive });
},
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
[isRowActive, onRowClick, onRowClickNewTab, rowData, getRowKeyData, rowIndex],
);
if (itemKind === 'expansion') {
@@ -120,7 +107,6 @@ function areRowCellsPropsEqual<TData>(
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&

View File

@@ -92,11 +92,11 @@ function TanStackTableInner<TData, TItemKey = string>(
getGroupKey,
getRowStyle,
getRowClassName,
getRowTestId,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
@@ -378,11 +378,11 @@ function TanStackTableInner<TData, TItemKey = string>(
() => ({
getRowStyle,
getRowClassName,
getRowTestId,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
colCount: visibleColumnsCount,
@@ -396,11 +396,11 @@ function TanStackTableInner<TData, TItemKey = string>(
[
getRowStyle,
getRowClassName,
getRowTestId,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
visibleColumnsCount,

View File

@@ -85,7 +85,9 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1');
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
});
it('fires onRowClick with empty itemKey when getRowKeyData is not provided', async () => {
@@ -117,17 +119,19 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '', {
isActive: false,
});
});
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
it('calls onRowClick with isActive: true when the row is active', async () => {
// The table no longer owns open/close — it reports the active state and the
// consumer routes the click. An active row must still fire onRowClick.
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
@@ -152,8 +156,44 @@ describe('TanStackRowCells', () => {
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: true,
});
});
it('calls onRowClick with isActive: false when the row is not active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
isRowActive: () => false,
getRowKeyData: () => ({ finalKey: 'r1', itemKey: 'r1' }),
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, 'r1', {
isActive: false,
});
});
it('does not render renderRowActions before hover', () => {

View File

@@ -611,6 +611,7 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
{ isActive: false },
);
});
@@ -639,6 +640,7 @@ describe('TanStackTableView Integration', () => {
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
{ id: '1', name: 'Item 1' },
{ isActive: false },
);
});
@@ -659,15 +661,15 @@ describe('TanStackTableView Integration', () => {
expect(row).toHaveClass('tableRowActive');
});
it('calls onRowDeactivate when clicking active row', async () => {
it('calls onRowClick with isActive: true when clicking the active row', async () => {
// The consumer owns open/close routing — the table just reports the
// active state via the click context.
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
@@ -678,8 +680,11 @@ describe('TanStackTableView Integration', () => {
await user.click(screen.getByText('Item 1'));
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
expect.anything(),
{ isActive: true },
);
});
it('opens in new tab on ctrl+click', async () => {

View File

@@ -116,9 +116,11 @@ export * from './useTableParams';
* getItemKey={(row) => row.id}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
* // The table reports the click + the row's active state; the consumer owns open/close.
* onRowClick={(row, itemKey, { isActive }) =>
* setSelectedId(isActive ? undefined : itemKey)
* }
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}

View File

@@ -81,15 +81,19 @@ export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type RowClickContext = {
isActive: boolean;
};
export type TableRowContext<TData, TItemKey = string> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
getRowTestId?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
@@ -178,12 +182,12 @@ export type TanStackTableProps<TData, TItemKey = string> = {
getGroupKey?: (row: TData) => Record<string, string>;
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
getRowTestId?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: TItemKey) => void;
onRowClick?: (row: TData, itemKey: TItemKey, context: RowClickContext) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: TItemKey) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,

View File

@@ -45,4 +45,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
}

View File

@@ -104,6 +104,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isFetchingFeatureFlags,
featureFlagsFetchError,
userPreferences,
isFetchingUserPreferences,
updateChangelog,
toggleChangelogModal,
showChangelogModal,
@@ -724,12 +725,12 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}
}, []);
// Set sidebar as loaded after user preferences are fetched
// Set sidebar as loaded after user preferences fetch completes (success or error)
useEffect(() => {
if (userPreferences !== null) {
if (!isFetchingUserPreferences) {
setIsSidebarLoaded(true);
}
}, [userPreferences]);
}, [isFetchingUserPreferences]);
// Use localStorage value as fallback until preferences are loaded
const isSideNavPinned = isSidebarLoaded

View File

@@ -15,6 +15,18 @@ jest.mock('@signozhq/ui/sonner', () => ({
},
}));
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
// Admin gating on the write controls flows through useAuthZ. Mock it directly
// (synchronous) so the functional tests stay synchronous; the read-only block
// below flips it to deny-all.
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
@@ -102,6 +114,8 @@ describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
window.history.pushState(null, '', '/');
// Default to an admin; the read-only block overrides this.
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
@@ -507,4 +521,55 @@ describe('AttributeMappingsTab (integration)', () => {
).resolves.toBeInTheDocument();
});
});
// The write APIs (create/update/delete group & mapper) are Admin-only on the
// backend, so a non-admin gets a read-only view: the data renders, but every
// write control is hidden.
describe('read-only (non-admin)', () => {
beforeEach(() => {
mockedUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
});
it('hides the "Add a new group" button', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
expect(screen.queryByTestId('add-group-row')).not.toBeInTheDocument();
});
it('hides the group enable toggle and actions menu', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
expect(
screen.queryByTestId('group-enabled-group-1'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('group-actions-group-1'),
).not.toBeInTheDocument();
});
it('renders mappers read-only: no "Add mapping" button or row actions', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
// The mapper still renders...
await screen.findByTestId('mapper-target-mapper-1');
// ...but every write control is gone.
expect(screen.queryByTestId('add-mapper-group-1')).not.toBeInTheDocument();
expect(
screen.queryByTestId('mapper-enabled-mapper-1'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Mapping actions' }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,6 +1,7 @@
import { Switch } from '@signozhq/ui/switch';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
import styles from './GroupHeaderActions.module.scss';
@@ -16,7 +17,11 @@ function GroupHeaderActions({
onToggle,
onEdit,
onRemove,
}: GroupHeaderActionsProps): JSX.Element {
}: GroupHeaderActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage) {
return null;
}
return (
<div
className={styles.actions}

View File

@@ -11,6 +11,7 @@ import {
Mapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import { COLUMN_COUNT } from '../constants';
import MapperRow from '../MapperRow/MapperRow';
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
@@ -39,6 +40,7 @@ function GroupMappers({
onEditMapper,
}: GroupMappersProps): JSX.Element {
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
const canManage = useCanManageAttributeMapping();
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
@@ -144,16 +146,20 @@ function GroupMappers({
/>
));
// The add-mapping row trails every non-error state (including loading/empty).
// The add-mapping row trails every non-error state (including loading/empty),
// but only for users who can manage mappings — non-admins get a read-only view.
let rows: JSX.Element[];
if (isErrorMappers) {
rows = [errorRow];
} else if (isLoadingMappers && mapperCount === 0) {
rows = [...skeletonRows, addMapperRow];
rows = [...skeletonRows];
} else if (mapperCount === 0) {
rows = [emptyRow, addMapperRow];
rows = [emptyRow];
} else {
rows = [...mapperRows, addMapperRow];
rows = [...mapperRows];
}
if (canManage && !isErrorMappers) {
rows.push(addMapperRow);
}
return (

View File

@@ -6,6 +6,7 @@ import cx from 'classnames';
import { motion } from 'motion/react';
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
import styles from './MapperRow.module.scss';
@@ -30,6 +31,7 @@ function MapperRow({
onRemove,
onToggle,
}: MapperRowProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
@@ -101,14 +103,16 @@ function MapperRow({
)}
</td>
<td className={cx(styles.cell, styles.actionsCell)}>
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
{canManage && (
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
)}
</td>
</motion.tr>
);

View File

@@ -11,6 +11,7 @@ import {
DraftMapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import GroupHeader from './GroupHeader/GroupHeader';
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
import GroupMappers from './GroupMappers/GroupMappers';
@@ -33,6 +34,7 @@ function MappingsTable({
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
const drawer = useMapperFormDrawer();
const canManage = useCanManageAttributeMapping();
const { upsertMapper, removeMapper } = editor;
@@ -120,18 +122,20 @@ function MappingsTable({
return (
<div className={styles.tableWrapper}>
<div className={styles.toolbar}>
<Button
variant="link"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
{canManage && (
<div className={styles.toolbar}>
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
)}
{isEmpty ? (
<div className={styles.tableState} data-testid="mapper-groups-empty">

View File

@@ -8,12 +8,18 @@ import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import TestTab from './TestTab/TestTab';
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
import { useTestSpanMapper } from './TestTab/useTestSpanMapper';
const MAPPINGS_TAB_KEY = 'attribute-mappings';
const TEST_TAB_KEY = 'test';
function LLMObservabilityAttributeMapping(): JSX.Element {
const editor = useAttributeMappingEditor();
const groupDrawer = useGroupFormDrawer();
const spanTest = useTestSpanMapper(editor.snapshot, editor.groups);
const { discard } = editor;
// Discarding wipes the whole working copy, so gate it behind a confirm
@@ -31,7 +37,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
const tabItems = [
{
key: 'attribute-mappings',
key: MAPPINGS_TAB_KEY,
label: 'Attribute Mappings',
children: (
<AttributeMappingsTab
@@ -42,11 +48,9 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
),
},
{
key: 'test',
key: TEST_TAB_KEY,
label: 'Test',
disabled: true,
disabledReason: 'Coming soon',
children: null,
children: <TestTab spanTest={spanTest} />,
},
];
@@ -71,7 +75,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
<Tabs
testId="attribute-mapping-tabs"
defaultValue="attribute-mappings"
defaultValue={MAPPINGS_TAB_KEY}
items={tabItems}
/>
{groupDrawer.isOpen && (

View File

@@ -0,0 +1,54 @@
.skeleton {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--l1-background);
}
.line {
display: flex;
align-items: center;
height: 18px;
}
.lineNumber {
flex: 0 0 40px;
padding-right: var(--padding-3);
text-align: right;
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
opacity: 0.6;
user-select: none;
}
.lineContent {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.bar {
height: 8px;
border-radius: var(--radius-1);
background: var(--l2-border);
animation: jsonSkeletonPulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
@keyframes jsonSkeletonPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}

View File

@@ -0,0 +1,56 @@
import styles from './JsonEditorSkeleton.module.scss';
interface SkeletonLine {
indent: number;
barWidths: number[];
}
const SKELETON_LINES: SkeletonLine[] = [
{ indent: 0, barWidths: [10] },
{ indent: 1, barWidths: [90, 10] },
{ indent: 2, barWidths: [155, 190] },
{ indent: 2, barWidths: [135, 190] },
{ indent: 2, barWidths: [150, 50] },
{ indent: 2, barWidths: [180, 35] },
{ indent: 2, barWidths: [185, 200] },
{ indent: 1, barWidths: [14] },
{ indent: 1, barWidths: [75, 10] },
{ indent: 2, barWidths: [95, 90] },
{ indent: 2, barWidths: [170, 85] },
{ indent: 1, barWidths: [10] },
{ indent: 0, barWidths: [10] },
];
const INDENT_WIDTH_PX = 14;
function JsonEditorSkeleton(): JSX.Element {
return (
<div
className={styles.skeleton}
data-testid="json-editor-skeleton"
aria-hidden="true"
>
{SKELETON_LINES.map((line, lineIndex) => (
// eslint-disable-next-line react/no-array-index-key
<div key={lineIndex} className={styles.line}>
<span className={styles.lineNumber}>{lineIndex + 1}</span>
<span
className={styles.lineContent}
style={{ paddingLeft: line.indent * INDENT_WIDTH_PX }}
>
{line.barWidths.map((width, barIndex) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={barIndex}
className={styles.bar}
style={{ width }}
/>
))}
</span>
</div>
))}
</div>
);
}
export default JsonEditorSkeleton;

View File

@@ -0,0 +1,121 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { useMemo } from 'react';
import {
AttrChangeStatus,
AttrDiffEntry,
diffAttributeMaps,
formatAttributeValue,
} from './testPayload';
import styles from './TestTab.module.scss';
import { TestTabAttributes, TestTabResource } from './useTestSpanMapper';
interface TestResultProps {
index: number;
span: SpantypesSpanMapperTestSpanDTO;
inputAttributes: TestTabAttributes;
inputResource: TestTabResource;
}
const STATUS_BADGE: Partial<
Record<
AttrChangeStatus,
{ color: 'success' | 'robin' | 'sienna'; label: string }
>
> = {
added: { color: 'success', label: 'populated' },
changed: { color: 'robin', label: 'remapped' },
removed: { color: 'sienna', label: 'moved out' },
};
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
added: styles.added,
removed: styles.removed,
};
interface ResultSection {
key: string;
title: string;
entries: AttrDiffEntry[];
}
function TestResult({
index,
span,
inputAttributes,
inputResource,
}: TestResultProps): JSX.Element {
const attributeEntries = useMemo(
() => diffAttributeMaps(inputAttributes, span.attributes ?? {}),
[inputAttributes, span.attributes],
);
const resourceEntries = useMemo(
() => diffAttributeMaps(inputResource, span.resource ?? {}),
[inputResource, span.resource],
);
const sections: ResultSection[] = [
{
key: 'attributes',
title: 'Resulting attributes',
entries: attributeEntries,
},
];
if (resourceEntries.length > 0) {
sections.push({
key: 'resource',
title: 'Resulting resource',
entries: resourceEntries,
});
}
return (
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
{sections.map((section) => (
<div
key={section.key}
className={styles.resultSection}
data-testid={`test-result-${index}-${section.key}`}
>
<div className={styles.resultTitle}>{section.title}</div>
{section.entries.length === 0 ? (
<div className={styles.resultEmpty}>No keys in this map.</div>
) : (
<div className={styles.attrRows}>
{section.entries.map((entry) => {
const badge = STATUS_BADGE[entry.status];
return (
<div
key={entry.key}
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
>
<span className={styles.attrKey} title={entry.key}>
{entry.key}
</span>
<span
className={styles.attrValue}
title={formatAttributeValue(entry.value)}
>
{formatAttributeValue(entry.value)}
</span>
{badge ? (
<Badge color={badge.color} variant="outline">
{badge.label}
</Badge>
) : (
<span />
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
export default TestResult;

View File

@@ -0,0 +1,177 @@
.testTab {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
margin-top: var(--spacing-6);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-6);
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex-shrink: 0;
}
.headerText {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.description {
margin: 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
height: calc(100vh - 320px);
min-height: 320px;
}
.editor {
width: 100%;
height: 100%;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}
.validationCallout {
width: 100%;
}
// No top padding so results align with the editor's first line, which sits
// flush against the top of its own panel.
.resultsPanel {
height: 100%;
overflow-y: auto;
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}
// Pre-run empty state that fills the blank right half.
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
height: 100%;
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.placeholderTitle {
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
}
.error {
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);
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.resultCard {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
border-radius: var(--radius-2);
background: var(--l1-background);
}
.resultSection {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.resultTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.resultEmpty {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.attrRows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.attrRow {
display: grid;
grid-template-columns: clamp(200px, 40%, 340px) minmax(0, 1fr) auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--padding-3);
border-radius: var(--radius-2);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
&.added {
background: var(--callout-success-background);
}
&.removed {
opacity: 0.65;
.attrKey,
.attrValue {
text-decoration: line-through;
}
}
}
.attrKey,
.attrValue {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attrKey {
font-weight: var(--font-weight-medium);
}
.attrValue {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,162 @@
import MEditor from '@monaco-editor/react';
import { Play, RotateCcw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { useIsDarkMode } from 'hooks/useDarkMode';
import styles from './TestTab.module.scss';
import JsonEditorSkeleton from './JsonEditorSkeleton';
import TestResult from './TestResult';
import {
defineSignozJsonTheme,
SIGNOZ_JSON_THEME_DARK,
SIGNOZ_JSON_THEME_LIGHT,
} from './jsonEditorTheme';
import { UseTestSpanMapper } from './useTestSpanMapper';
interface TestTabProps {
spanTest: UseTestSpanMapper;
}
function TestTab({ spanTest }: TestTabProps): JSX.Element {
const {
input,
setInput,
run,
resetToTemplate,
isTemplateInput,
isRunning,
result,
testedAttributes,
testedResource,
error,
validationError,
} = spanTest;
const isDarkMode = useIsDarkMode();
function renderResults(): JSX.Element {
if (error) {
return (
<div className={styles.error} role="alert" data-testid="test-error">
{error}
</div>
);
}
if (result) {
if (result.length === 0) {
return (
<div className={styles.resultEmpty} data-testid="test-results-empty">
No spans returned. The mappers produced no output for this input.
</div>
);
}
return (
<div className={styles.results} data-testid="test-results">
{result.map((span, index) => (
<TestResult
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
span={span}
inputAttributes={testedAttributes ?? {}}
inputResource={testedResource ?? {}}
/>
))}
</div>
);
}
return (
<div className={styles.placeholder} data-testid="test-results-placeholder">
<span className={styles.placeholderTitle}>No results yet</span>
<span>
Run the test to see which target attributes your mappers populate.
</span>
</div>
);
}
return (
<div className={styles.testTab} data-testid="test-tab">
<div className={styles.header}>
<div className={styles.headerText}>
<h3 className={styles.heading}>Test with sample span</h3>
<p className={styles.description}>
Paste a JSON span object to see which target attributes get populated and
which source key matched.
</p>
</div>
<div className={styles.headerActions}>
<Button
testId="reset-template-button"
variant="outlined"
color="secondary"
prefix={<RotateCcw size={14} />}
onClick={resetToTemplate}
disabled={isTemplateInput}
>
Reset to Default Span
</Button>
<Button
testId="run-test-button"
variant="solid"
color="primary"
prefix={<Play size={14} />}
onClick={run}
loading={isRunning}
disabled={isRunning || validationError !== null}
>
Run Test
</Button>
</div>
</div>
<div className={styles.body}>
<div
className={styles.editor}
data-testid="test-span-input"
aria-label="Sample span JSON"
>
<MEditor
language="json"
value={input}
onChange={(value): void => setInput(value ?? '')}
height="100%"
loading={<JsonEditorSkeleton />}
theme={isDarkMode ? SIGNOZ_JSON_THEME_DARK : SIGNOZ_JSON_THEME_LIGHT}
beforeMount={defineSignozJsonTheme}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
formatOnPaste: true,
tabSize: 2,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
</div>
<div className={styles.resultsPanel} data-testid="test-results-panel">
{renderResults()}
</div>
</div>
{validationError && (
<Callout
type="error"
size="small"
showIcon
title={validationError}
testId="test-input-error"
className={styles.validationCallout}
/>
)}
</div>
);
}
export default TestTab;

View File

@@ -0,0 +1,38 @@
//TODO: This is a local theme for now, but ideally for the editor as well. Just like prettyViewer, we have a theme. We should have a theme for the editor as well.
import { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
export const SIGNOZ_JSON_THEME_DARK = 'signoz-attr-mapping-json-dark';
export const SIGNOZ_JSON_THEME_LIGHT = 'signoz-attr-mapping-json-light';
const SHARED_THEME = {
inherit: true,
colors: {
'editor.background': '#00000000', // transparent — inherit the panel bg
},
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
fontWeight: 'normal',
lineHeight: 18,
letterSpacing: -0.06,
};
export function defineSignozJsonTheme(monaco: Monaco): void {
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_DARK, {
...SHARED_THEME,
base: 'vs-dark',
rules: [
{ token: 'string.key.json', foreground: Color.BG_ROBIN_400 },
{ token: 'string.value.json', foreground: Color.BG_VANILLA_400 },
],
});
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_LIGHT, {
...SHARED_THEME,
base: 'vs',
rules: [
{ token: 'string.key.json', foreground: Color.BG_ROBIN_500 },
{ token: 'string.value.json', foreground: Color.BG_INK_400 },
],
});
}

View File

@@ -0,0 +1,53 @@
import get from 'api/browser/localstorage/get';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { parseSpanInput } from './testPayload';
export const SAMPLE_SPAN_JSON = `{
"attributes": {
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
},
"resource": {
"service.name": "llm-gateway",
"deployment.environment": "production"
}
}`;
function hasNoSpanData(input: string): boolean {
let span;
try {
span = parseSpanInput(input);
} catch {
return false;
}
return (
Object.keys(span.attributes ?? {}).length === 0 &&
Object.keys(span.resource ?? {}).length === 0
);
}
export function getStoredSpanInput(): string {
const stored = get(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
if (!stored?.trim() || hasNoSpanData(stored)) {
return SAMPLE_SPAN_JSON;
}
return stored;
}
export function setStoredSpanInput(value: string): void {
if (!value.trim() || hasNoSpanData(value)) {
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
return;
}
set(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN, value);
}
export function clearStoredSpanInput(): void {
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
}

View File

@@ -0,0 +1,145 @@
import {
SpantypesPostableSpanMapperTestDTO,
SpantypesPostableSpanMapperTestGroupDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import { DraftGroup } from '../types';
import {
buildPostableGroup,
buildPostableMapper,
groupDraftFromNode,
mapperDraftFromNode,
} from '../utils';
function shouldSendMappers(
snap: DraftGroup | undefined,
group: DraftGroup,
): boolean {
if (!snap) {
return true;
}
return !isEqual(snap.mappers, group.mappers);
}
export function buildTestGroups(
snapshot: DraftGroup[],
draft: DraftGroup[],
): SpantypesPostableSpanMapperTestGroupDTO[] {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
return draft.map((group) => {
const base = buildPostableGroup(groupDraftFromNode(group));
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
return {
...base,
mappers: shouldSendMappers(snap, group)
? group.mappers.map((mapper) =>
buildPostableMapper(mapperDraftFromNode(mapper)),
)
: null,
};
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Paste a JSON span object to run the test.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
'Invalid JSON — check for trailing commas or missing quotes.',
);
}
if (!isPlainObject(parsed)) {
throw new Error('Span must be a JSON object of attribute key-value pairs.');
}
if (isSpanEnvelope(parsed)) {
return {
attributes: isPlainObject(parsed.attributes) ? parsed.attributes : {},
resource: isPlainObject(parsed.resource) ? parsed.resource : {},
};
}
return { attributes: parsed, resource: {} };
}
export function buildTestRequest(
snapshot: DraftGroup[],
draft: DraftGroup[],
input: string,
): SpantypesPostableSpanMapperTestDTO {
return {
groups: buildTestGroups(snapshot, draft),
spans: [parseSpanInput(input)],
};
}
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
export interface AttrDiffEntry {
key: string;
value: unknown;
status: AttrChangeStatus;
}
// Renders a diff value for display: strings as-is, everything else JSON-encoded.
export function formatAttributeValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
export function diffAttributeMaps(
inputAttributes: Record<string, unknown>,
resultAttributes: Record<string, unknown>,
): AttrDiffEntry[] {
const added: AttrDiffEntry[] = [];
const changed: AttrDiffEntry[] = [];
const unchanged: AttrDiffEntry[] = [];
const removed: AttrDiffEntry[] = [];
Object.entries(resultAttributes).forEach(([key, value]) => {
if (!(key in inputAttributes)) {
added.push({ key, value, status: 'added' });
} else if (!isEqual(inputAttributes[key], value)) {
changed.push({ key, value, status: 'changed' });
} else {
unchanged.push({ key, value, status: 'unchanged' });
}
});
Object.entries(inputAttributes).forEach(([key, value]) => {
if (!(key in resultAttributes)) {
removed.push({ key, value, status: 'removed' });
}
});
return [...added, ...changed, ...unchanged, ...removed];
}

View File

@@ -0,0 +1,155 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
import { AxiosError } from 'axios';
import { debounce } from 'lodash-es';
import { buildTestRequest, parseSpanInput } from './testPayload';
import {
clearStoredSpanInput,
getStoredSpanInput,
SAMPLE_SPAN_JSON,
setStoredSpanInput,
} from './spanInputStorage';
import { DraftGroup } from '../types';
export type TestTabAttributes = Record<string, unknown>;
export type TestTabResource = Record<string, unknown>;
const PERSIST_DEBOUNCE_MS = 500;
function apiErrorMessage(error: unknown): string {
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
return (
axiosError?.response?.data?.error?.message ??
(error instanceof Error ? error.message : 'Test failed. Please try again.')
);
}
export interface UseTestSpanMapper {
input: string;
setInput: (value: string) => void;
run: () => void;
reset: () => void;
resetToTemplate: () => void;
isTemplateInput: boolean;
isRunning: boolean;
validationError: string | null;
result: SpantypesSpanMapperTestSpanDTO[] | null;
testedAttributes: TestTabAttributes | null;
testedResource: TestTabResource | null;
error: string | null;
}
export function useTestSpanMapper(
snapshot: DraftGroup[],
draft: DraftGroup[],
): UseTestSpanMapper {
const [input, setInputValue] = useState<string>(getStoredSpanInput);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
null,
);
const [testedAttributes, setTestedAttributes] =
useState<TestTabAttributes | null>(null);
const [testedResource, setTestedResource] = useState<TestTabResource | null>(
null,
);
const { mutate, isLoading } = useTestSpanMappers();
const persistInput = useMemo(
() => debounce(setStoredSpanInput, PERSIST_DEBOUNCE_MS),
[],
);
useEffect(
() => (): void => {
persistInput.flush();
},
[persistInput],
);
const setInput = useCallback(
(value: string): void => {
setInputValue(value);
persistInput(value);
},
[persistInput],
);
const validationError = useMemo((): string | null => {
try {
parseSpanInput(input);
return null;
} catch (err) {
return err instanceof Error ? err.message : 'Invalid span JSON.';
}
}, [input]);
const reset = useCallback((): void => {
setError(null);
setResult(null);
setTestedAttributes(null);
setTestedResource(null);
}, []);
const resetToTemplate = useCallback((): void => {
persistInput.cancel();
clearStoredSpanInput();
setInputValue(SAMPLE_SPAN_JSON);
reset();
}, [persistInput, reset]);
const isTemplateInput = input.trim() === SAMPLE_SPAN_JSON;
const run = useCallback((): void => {
reset();
let body;
try {
body = buildTestRequest(snapshot, draft, input);
} catch (parseError) {
setError(apiErrorMessage(parseError));
return;
}
const submittedSpan = body.spans?.[0];
const submittedAttributes = (submittedSpan?.attributes ??
{}) as TestTabAttributes;
const submittedResource = (submittedSpan?.resource ?? {}) as TestTabResource;
mutate(
{ data: body },
{
onSuccess: (response) => {
setTestedAttributes(submittedAttributes);
setTestedResource(submittedResource);
setResult(response.data?.spans ?? []);
},
onError: (mutationError) => {
setResult(null);
setError(apiErrorMessage(mutationError));
},
},
);
}, [snapshot, draft, input, mutate, reset]);
return {
input,
setInput,
run,
reset,
resetToTemplate,
isTemplateInput,
isRunning: isLoading,
validationError,
result,
testedAttributes,
testedResource,
error,
};
}

View File

@@ -1,6 +1,32 @@
import { rest, server } from 'mocks-server/server';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The header's Save/Discard and the group toggle are Admin-gated via useAuthZ;
// render as an admin so those controls are present.
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
// Monaco can't run in jsdom — stand in a textarea so the span input is editable.
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({
value,
onChange,
}: {
value: string;
onChange: (next?: string) => void;
}): JSX.Element => (
<textarea
aria-label="span-json-editor"
data-testid="span-json-editor"
value={value}
onChange={(event): void => onChange(event.target.value)}
/>
),
}));
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
@@ -16,6 +42,7 @@ describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
@@ -86,6 +113,26 @@ describe('LLMObservabilityAttributeMapping', () => {
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
});
it('keeps the sample span input when switching away from the test tab and back', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const editor = await screen.findByTestId('span-json-editor');
await user.clear(editor);
await user.type(editor, '{{ "my.attr": 1 }');
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
await screen.findByTestId('attribute-mappings-tab');
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Test' }));
await expect(screen.findByTestId('span-json-editor')).resolves.toHaveValue(
'{ "my.attr": 1 }',
);
});
it('keeps staged changes when the discard prompt is dismissed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservabilityAttributeMapping />);

View File

@@ -1,6 +1,7 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
@@ -16,12 +17,13 @@ function AttributeMappingHeader({
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{isDirty && (
{canManage && isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes

View File

@@ -35,6 +35,7 @@ function clone(groups: DraftGroup[]): DraftGroup[] {
export interface AttributeMappingEditor {
groups: DraftGroup[];
snapshot: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
@@ -304,6 +305,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
return {
groups: draft ?? [],
snapshot,
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,

View File

@@ -0,0 +1,9 @@
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
const ADMIN_PERMISSION = [IsAdminPermission];
export function useCanManageAttributeMapping(): boolean {
const { allowed } = useAuthZ(ADMIN_PERMISSION);
return allowed;
}

View File

@@ -60,3 +60,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -204,6 +204,9 @@ function LiveLogsList({
data={formattedLogs}
isLoading={false}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -216,10 +219,13 @@ function LiveLogsList({
),
}) as CSSProperties
}
onRowClick={(log): void => {
handleSetActiveLog(log);
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons

View File

@@ -323,3 +323,7 @@
}
}
}
.logs-linked-row td {
background-color: var(--row-active-bg) !important;
}

View File

@@ -211,9 +211,11 @@ function LogsExplorerList({
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowClassName={(log): string =>
log.id === activeLogId ? 'logs-linked-row' : ''
}
getRowTestId={(log): string => `logs-table-row-${log.id}`}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
@@ -226,10 +228,13 @@ function LogsExplorerList({
),
}) as CSSProperties
}
onRowClick={(log): void => {
handleSetActiveLog(log);
onRowClick={(log, _itemKey, { isActive }): void => {
if (isActive) {
handleCloseLogDetail();
} else {
handleSetActiveLog(log);
}
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
@@ -282,6 +287,7 @@ function LogsExplorerList({
options.maxLines,
options.fontSize,
activeLogIndex,
activeLogId,
logs,
onEndReached,
getItemContent,

View File

@@ -151,7 +151,9 @@ describe('JsonEditor', () => {
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'test-key-123{enter}');
await switchToJsonMode();

View File

@@ -1,17 +1,8 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = await screen.findByTestId('expand-all-button');
await user.click(expandButton);
}
import { expandAllCards, renderCreateRolePage } from './testUtils';
beforeEach(() => {
server.use(setupAuthzAdmin());
@@ -21,35 +12,16 @@ afterEach(() => {
server.resetHandlers();
});
async function renderPage(): Promise<ReturnType<typeof render>> {
const result = render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>
</TooltipProvider>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
describe('PermissionEditor', () => {
describe('mode toggle', () => {
it('renders permission editor with testId', async () => {
await renderPage();
await renderCreateRolePage();
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
});
it('defaults to interactive mode', async () => {
await renderPage();
await renderCreateRolePage();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
@@ -59,7 +31,7 @@ describe('PermissionEditor', () => {
it('switches to JSON mode when clicked', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -70,7 +42,7 @@ describe('PermissionEditor', () => {
it('switches back to interactive mode', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -87,7 +59,7 @@ describe('PermissionEditor', () => {
describe('resource cards', () => {
it('renders all resource cards', async () => {
await renderPage();
await renderCreateRolePage();
expect(
screen.getByTestId('resource-card-factor-api-key'),
@@ -99,7 +71,7 @@ describe('PermissionEditor', () => {
});
it('resource cards are collapsed by default', async () => {
await renderPage();
await renderCreateRolePage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -111,7 +83,7 @@ describe('PermissionEditor', () => {
it('expands resource card when header clicked', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -125,7 +97,7 @@ describe('PermissionEditor', () => {
it('collapses expanded resource card when header clicked again', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -139,7 +111,7 @@ describe('PermissionEditor', () => {
});
it('shows granted count in resource card header', async () => {
await renderPage();
await renderCreateRolePage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
@@ -150,7 +122,7 @@ describe('PermissionEditor', () => {
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -169,7 +141,7 @@ describe('PermissionEditor', () => {
});
it('defaults all actions to None scope', async () => {
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -187,7 +159,7 @@ describe('PermissionEditor', () => {
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -208,7 +180,7 @@ describe('PermissionEditor', () => {
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -227,7 +199,7 @@ describe('PermissionEditor', () => {
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -239,12 +211,14 @@ describe('PermissionEditor', () => {
await within(createToggle).findByText('Only selected');
await user.click(onlySelectedBtn);
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
expect(
screen.getByTestId('item-input-selector-factor-api-key-read'),
).toBeInTheDocument();
});
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -254,7 +228,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'api-key-001{enter}');
await expect(screen.findByText('api-key-001')).resolves.toBeInTheDocument();
@@ -262,7 +238,7 @@ describe('PermissionEditor', () => {
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -272,10 +248,14 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'api-key-002');
const addBtn = screen.getByTestId('item-input-selector-add-btn');
const addBtn = screen.getByTestId(
'item-input-selector-add-btn-factor-api-key-read',
);
await user.click(addBtn);
await expect(screen.findByText('api-key-002')).resolves.toBeInTheDocument();
@@ -283,7 +263,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -293,7 +273,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-a, key-b, key-c{enter}');
await expect(screen.findByText('key-a')).resolves.toBeInTheDocument();
@@ -303,7 +285,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -313,7 +295,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-x key-y key-z{enter}');
await expect(screen.findByText('key-x')).resolves.toBeInTheDocument();
@@ -323,7 +307,7 @@ describe('PermissionEditor', () => {
it('does not add duplicate items', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -333,7 +317,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'same-key{enter}');
await user.type(input, 'same-key{enter}');
@@ -343,7 +329,7 @@ describe('PermissionEditor', () => {
it('removes item when X clicked', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -353,20 +339,138 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'removable-key{enter}');
const removeBtn = screen.getByRole('button', {
name: /remove removable-key/i,
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
const removeBtn = within(badge).getByRole('button', {
name: 'Remove removable-key',
});
await user.click(removeBtn);
expect(screen.queryByText('removable-key')).not.toBeInTheDocument();
});
it('names each badge close button after the item it removes', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-one key-two{enter}');
const firstBadge = await screen.findByTestId(
'item-badge-factor-api-key-read-0',
);
const secondBadge = screen.getByTestId('item-badge-factor-api-key-read-1');
expect(
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
).toBeInTheDocument();
expect(
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
).toBeInTheDocument();
});
it('exposes the full item value as a title so truncated badges stay readable', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'a-very-long-api-key-identifier-000001{enter}');
const badge = await screen.findByTestId('item-badge-factor-api-key-read-0');
expect(
within(badge).getByTitle('a-very-long-api-key-identifier-000001'),
).toBeInTheDocument();
});
it('moves focus to the previous badge when closed with the keyboard', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-one key-two{enter}');
const secondBadge = await screen.findByTestId(
'item-badge-factor-api-key-read-1',
);
within(secondBadge).getByRole('button', { name: 'Remove key-two' }).focus();
await user.keyboard('{Enter}');
await waitFor(() => {
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
expect(
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
).toHaveFocus();
});
});
it('does not steal focus when a badge is closed with the mouse', async () => {
const user = userEvent.setup();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
await user.click(await within(readToggle).findByText('Only selected'));
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'key-one key-two{enter}');
const secondBadge = await screen.findByTestId(
'item-badge-factor-api-key-read-1',
);
await user.click(
within(secondBadge).getByRole('button', { name: 'Remove key-two' }),
);
await waitFor(() => {
expect(screen.queryByText('key-two')).not.toBeInTheDocument();
});
const firstBadge = screen.getByTestId('item-badge-factor-api-key-read-0');
expect(
within(firstBadge).getByRole('button', { name: 'Remove key-one' }),
).not.toHaveFocus();
});
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -376,7 +480,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const addBtn = screen.getByTestId('item-input-selector-add-btn');
const addBtn = screen.getByTestId(
'item-input-selector-add-btn-factor-api-key-read',
);
expect(addBtn).toBeDisabled();
});
});
@@ -384,7 +490,7 @@ describe('PermissionEditor', () => {
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -394,7 +500,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'will-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
@@ -406,7 +514,7 @@ describe('PermissionEditor', () => {
it('clears items when confirmed', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -416,7 +524,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'to-be-cleared{enter}');
await user.click(await within(createToggle).findByText('All'));
@@ -433,7 +543,7 @@ describe('PermissionEditor', () => {
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -443,7 +553,9 @@ describe('PermissionEditor', () => {
await user.click(await within(createToggle).findByText('Only selected'));
const input = screen.getByTestId('item-input-selector-input');
const input = screen.getByTestId(
'item-input-selector-input-factor-api-key-read',
);
await user.type(input, 'preserved-key{enter}');
await user.click(await within(createToggle).findByText('None'));
@@ -455,12 +567,14 @@ describe('PermissionEditor', () => {
screen.findByText('preserved-key'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('item-input-selector')).toBeInTheDocument();
expect(
screen.getByTestId('item-input-selector-factor-api-key-read'),
).toBeInTheDocument();
});
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -479,7 +593,7 @@ describe('PermissionEditor', () => {
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -501,7 +615,7 @@ describe('PermissionEditor', () => {
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', async () => {
await renderPage();
await renderCreateRolePage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
@@ -509,7 +623,7 @@ describe('PermissionEditor', () => {
});
it('expands all cards when expand button clicked', async () => {
await renderPage();
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -523,7 +637,7 @@ describe('PermissionEditor', () => {
describe('resource card error states', () => {
it('shows error border on collapsed card with validation error', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -553,7 +667,7 @@ describe('PermissionEditor', () => {
it('hides error border when card is expanded', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -590,7 +704,7 @@ describe('PermissionEditor', () => {
it('clears validation error when permission is changed', async () => {
const user = userEvent.setup();
await renderPage();
await renderCreateRolePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -0,0 +1,462 @@
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { expandAllCards, renderCreateRolePage } from './testUtils';
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
server.resetHandlers();
});
async function selectOnlySelected(
user: ReturnType<typeof userEvent.setup>,
resource = 'logs',
): Promise<void> {
await renderCreateRolePage();
await expandAllCards();
const card = screen.getByTestId(`resource-card-${resource}`);
const readToggle = within(card).getByTestId(`action-toggle-${resource}-read`);
await user.click(await within(readToggle).findByText('Only selected'));
}
async function selectLogsOnlySelected(
user: ReturnType<typeof userEvent.setup>,
): Promise<void> {
await selectOnlySelected(user, 'logs');
}
async function openWizard(
user: ReturnType<typeof userEvent.setup>,
resource = 'logs',
): Promise<void> {
await selectOnlySelected(user, resource);
await user.click(
screen.getByTestId(`telemetry-wizard-trigger-${resource}-read`),
);
await screen.findByTestId(`telemetry-wizard-dialog-${resource}-read`);
}
async function openLogsWizard(
user: ReturnType<typeof userEvent.setup>,
): Promise<void> {
await openWizard(user, 'logs');
}
describe('PermissionEditor - TelemetrySelectorWizard', () => {
it('shows wizard button for telemetry resources', async () => {
const user = userEvent.setup();
await selectLogsOnlySelected(user);
expect(
screen.getByTestId('telemetry-wizard-trigger-logs-read'),
).toBeInTheDocument();
});
it('does not show wizard button for non-telemetry resources', async () => {
await renderCreateRolePage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const readToggle = within(apiKeyCard).getByTestId(
'action-toggle-factor-api-key-read',
);
const user = userEvent.setup();
await user.click(await within(readToggle).findByText('Only selected'));
expect(
screen.queryByTestId('telemetry-wizard-trigger-logs-read'),
).not.toBeInTheDocument();
});
it('opens wizard dialog when trigger clicked', async () => {
const user = userEvent.setup();
await selectLogsOnlySelected(user);
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
await expect(
screen.findByTestId('telemetry-wizard-dialog-logs-read'),
).resolves.toBeInTheDocument();
});
it('adds a query-type wildcard when the value is left empty', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/*'),
).resolves.toBeInTheDocument();
});
it('does not offer builder sub query', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
expect(screen.queryByText('Builder Sub Query')).not.toBeInTheDocument();
});
it('does not show PromQL for logs', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
expect(
screen.queryByTestId('wizard-query-type-option-promql-logs-read'),
).not.toBeInTheDocument();
});
it('does not show PromQL for traces', async () => {
const user = userEvent.setup();
await openWizard(user, 'traces');
await user.click(screen.getByTestId('wizard-query-type-select-traces-read'));
expect(
screen.queryByTestId('wizard-query-type-option-promql-traces-read'),
).not.toBeInTheDocument();
});
it('allows PromQL for metrics', async () => {
const user = userEvent.setup();
await openWizard(user, 'metrics');
await user.click(screen.getByTestId('wizard-query-type-select-metrics-read'));
await user.click(
await screen.findByTestId('wizard-query-type-option-promql-metrics-read'),
);
await user.click(screen.getByTestId('wizard-add-btn-metrics-read'));
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
});
it('hardcodes the key and does not let it be edited', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
expect(keyInput).toHaveValue('signoz.workspace.key.id');
expect(keyInput).toBeDisabled();
});
it('hides Key field for query types that do not support key scoping', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
expect(
screen.queryByTestId('wizard-key-input-logs-read'),
).not.toBeInTheDocument();
});
it('adds a key-scoped selector when the value is filled', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/signoz.workspace.key.id/123',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/signoz.workspace.key.id/123'),
).resolves.toBeInTheDocument();
});
it('replaces the value with a wildcard when any resource is checked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByLabelText('Any value'));
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('*');
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/signoz.workspace.key.id/*'),
).resolves.toBeInTheDocument();
});
it('clears the value when any resource is unchecked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const anyResource = screen.getByLabelText('Any value');
await user.click(anyResource);
expect(anyResource).toBeChecked();
await user.click(anyResource);
expect(anyResource).not.toBeChecked();
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
});
it('checks any resource when the value is typed as a wildcard', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '*');
expect(screen.getByLabelText('Any value')).toBeChecked();
});
it('disables value scoping for query types that do not support it', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
expect(screen.getByTestId('wizard-value-input-logs-read')).toBeDisabled();
expect(screen.getByLabelText('Any value')).toBeDisabled();
});
it('clears the value when switching to a query type without key scoping', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'clickhouse_sql/*',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('clickhouse_sql/*'),
).resolves.toBeInTheDocument();
});
it('follows a hand-edited selector on the query type and value inputs', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'clickhouse_sql/signoz.workspace.key.id/checkout',
);
const selectTrigger = screen.getByTestId(
'wizard-query-type-select-logs-read',
);
expect(within(selectTrigger).getByText('ClickHouse SQL')).toBeInTheDocument();
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
'checkout',
);
});
it('checks any resource when the selector is edited to a wildcard value', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/*');
expect(screen.getByLabelText('Any value')).toBeChecked();
});
it('keeps the key input hardcoded when the selector uses another key', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
});
it('restores the hardcoded key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/frontend2',
);
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toBeDisabled();
});
it('blocks adding when the selector has an unknown query type', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'sql_query/*');
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('"sql_query" is not a supported query type.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
});
it('blocks adding when the selector is emptied', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.clear(screen.getByTestId('wizard-selector-input-logs-read'));
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Enter a selector.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).toBeDisabled();
});
it('adds the hand-edited selector verbatim', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/signoz.workspace.key.id/a/b');
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/signoz.workspace.key.id/a/b'),
).resolves.toBeInTheDocument();
});
it('closes dialog after adding selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await waitFor(() => {
expect(
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
).not.toBeInTheDocument();
});
});
it('does not add duplicate selectors', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
await screen.findByTestId('telemetry-wizard-dialog-logs-read');
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
const badges = screen.getAllByText('builder_query/*');
expect(badges).toHaveLength(1);
});
it('resets wizard state when dialog is closed and reopened', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
await user.click(await screen.findByText('ClickHouse SQL'));
await user.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(
screen.queryByTestId('telemetry-wizard-dialog-logs-read'),
).not.toBeInTheDocument();
});
await user.click(screen.getByTestId('telemetry-wizard-trigger-logs-read'));
const selectTrigger = await screen.findByTestId(
'wizard-query-type-select-logs-read',
);
expect(within(selectTrigger).getByText('Builder Query')).toBeInTheDocument();
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue('');
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/*',
);
});
it('previews the query-type wildcard while the value is empty', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/*',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow every "Builder Query" query.');
});
it('describes a key-scoped selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '123');
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent(
'Allow signoz.workspace.key.id=123 for Builder Query queries.',
);
});
it('describes an any-resource selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByLabelText('Any value'));
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent(
'Allow every signoz.workspace.key.id for Builder Query queries.',
);
});
it('does not show query type descriptions', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.click(screen.getByTestId('wizard-query-type-select-logs-read'));
expect(
screen.queryByText(
'Visual query builder for selecting data sources, filters, and aggregations',
),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,33 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { render, screen, userEvent } from 'tests/test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
export async function renderCreateRolePage(): Promise<
ReturnType<typeof render>
> {
const result = render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
</Route>
<Route path={ROUTES.ROLE_CREATE}>
<CreateEditRolePage />
</Route>
</Switch>
</TooltipProvider>,
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
export async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = await screen.findByTestId('expand-all-button');
await user.click(expandButton);
}

View File

@@ -7,6 +7,7 @@ import { Typography } from '@signozhq/ui/typography';
import { PermissionScope } from '../../types';
import { getResourcePanel } from '../../permissions.config';
import ItemInputSelector from './ItemInputSelector';
import TelemetrySelectorWizard from './TelemetrySelectorWizard';
import styles from './ActionToggle.module.scss';
import { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
@@ -39,10 +40,13 @@ function ActionToggle({
onSelectedIdsChange,
hasError = false,
}: ActionToggleProps): JSX.Element {
const panel = getResourcePanel(resource);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingScope, setPendingScope] = useState<PermissionScope | null>(null);
const displayLabel = getActionLabel(action);
const selectorTestId = `${resource}-${action}`;
const scopeItems: Array<{ value: PermissionScope; label: string }> =
useMemo(() => {
@@ -121,11 +125,25 @@ function ActionToggle({
<Divider />
<ItemInputSelector
placeholder={getResourcePanel(resource).selectorPlaceholder}
placeholder={panel.selectorPlaceholder}
selectedIds={selectedIds}
onChange={onSelectedIdsChange}
docsAnchor={getResourcePanel(resource).docsAnchor}
testId={selectorTestId}
docsAnchor={panel.docsAnchor}
hasError={hasError}
prefixElement={
panel.selectorType === 'telemetryBuilder' ? (
<TelemetrySelectorWizard
resource={resource}
testId={selectorTestId}
onAdd={(selector): void => {
if (!selectedIds.includes(selector)) {
onSelectedIdsChange([...selectedIds, selector]);
}
}}
/>
) : null
}
/>
</div>
)}

View File

@@ -4,9 +4,10 @@
gap: var(--spacing-4);
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: 4px;
border-radius: var(--radius-2);
padding: var(--spacing-4);
--input-prefix-padding: var(--spacing-2);
--input-suffix-padding: var(--spacing-2);
}
@@ -41,6 +42,11 @@
overflow-y: auto;
}
.itemInputSelectorBadge {
--badge-border-radius: 4px;
--badge-hover-background: var(--l2-background) !important;
}
.itemInputSelectorInfoIcon {
flex-shrink: 0;
color: var(--l2-foreground);
@@ -51,55 +57,7 @@
}
}
.itemInputSelectorBadge {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 4px 2px 6px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
}
.itemInputSelectorBadgeLabel {
flex: 1;
min-width: 0;
}
.itemInputSelectorBadgeRemove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
background: transparent;
border: none;
border-radius: 2px;
color: var(--l2-foreground);
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.itemInputSelectorHint {
margin: 0;
color: var(--l2-foreground);
a {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react';
import { Info, Plus, X } from '@signozhq/icons';
import { Info, Plus } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
@@ -15,8 +16,10 @@ export interface ItemInputSelectorProps {
placeholder: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
testId: string;
docsAnchor?: string;
hasError?: boolean;
prefixElement?: React.ReactNode;
}
function parseInputValues(input: string): string[] {
@@ -30,11 +33,13 @@ function ItemInputSelector({
placeholder,
selectedIds,
onChange,
testId,
docsAnchor = 'role',
hasError = false,
prefixElement,
}: ItemInputSelectorProps): JSX.Element {
const [inputValue, setInputValue] = useState('');
const footerRef = useRef<HTMLDivElement>(null);
const badgesRef = useRef<HTMLDivElement>(null);
const addValues = useCallback(
(input: string): void => {
@@ -87,26 +92,24 @@ function ItemInputSelector({
[selectedIds, onChange],
);
const handleBadgeKeyDown = useCallback(
(
e: React.KeyboardEvent<HTMLButtonElement>,
itemId: string,
index: number,
): void => {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
const handleBadgeClose = useCallback(
(e: React.MouseEvent, itemId: string, index: number): void => {
e.preventDefault();
handleRemove(itemId);
// Activating a button via Enter/Space reports detail 0;
// a real click reports 1 or more
// Only trigger focus when using keyboard
const isKeyboardActivation = e.detail === 0;
if (!isKeyboardActivation) {
return;
}
const targetIndex = index > 0 ? index - 1 : 0;
requestAnimationFrame(() => {
const buttons = footerRef.current?.querySelectorAll('button');
const targetButton = buttons?.[targetIndex] as
| HTMLButtonElement
| undefined;
targetButton?.focus();
const buttons = badgesRef.current?.querySelectorAll('button');
buttons?.[targetIndex]?.focus();
});
},
[handleRemove],
@@ -120,7 +123,7 @@ function ItemInputSelector({
styles.itemInputSelector,
showError ? styles.itemInputSelectorError : '',
)}
data-testid="item-input-selector"
data-testid={`item-input-selector-${testId}`}
>
<Input
placeholder={placeholder}
@@ -128,14 +131,15 @@ function ItemInputSelector({
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
data-testid="item-input-selector-input"
data-testid={`item-input-selector-input-${testId}`}
prefix={prefixElement}
suffix={
<Button
variant="solid"
size="sm"
onClick={handleAddClick}
disabled={!inputValue.trim()}
data-testid="item-input-selector-add-btn"
data-testid={`item-input-selector-add-btn-${testId}`}
>
<Plus size={14} />
Add
@@ -144,28 +148,22 @@ function ItemInputSelector({
/>
{selectedIds.length > 0 ? (
<div ref={footerRef} className={styles.itemInputSelectorFooter}>
<div className={styles.itemInputSelectorBadges}>
<div className={styles.itemInputSelectorFooter}>
<div ref={badgesRef} className={styles.itemInputSelectorBadges}>
{selectedIds.map((id, index) => (
<span key={id} className={styles.itemInputSelectorBadge} title={id}>
<Typography
as="span"
size="small"
truncate={1}
className={styles.itemInputSelectorBadgeLabel}
>
<Badge
key={id}
color="secondary"
className={styles.itemInputSelectorBadge}
testId={`item-badge-${testId}-${index}`}
closable
closeAriaLabel={`Remove ${id}`}
onClose={(e): void => handleBadgeClose(e, id, index)}
>
<Typography as="span" size="small" truncate={1} title={id}>
{id}
</Typography>
<button
type="button"
className={styles.itemInputSelectorBadgeRemove}
onClick={(): void => handleRemove(id)}
onKeyDown={(e): void => handleBadgeKeyDown(e, id, index)}
aria-label={`Remove ${id}`}
>
<X size={10} />
</button>
</span>
</Badge>
))}
</div>
<TooltipSimple

View File

@@ -0,0 +1,50 @@
// intentionally omitting few query types
// from pkg/types/querybuildertypes/querybuildertypesv5/query_type.go
export type QueryTypeId = 'builder_query' | 'promql' | 'clickhouse_sql';
export interface QueryTypeOption {
id: QueryTypeId;
label: string;
supportsKeyScoping: boolean;
metricsOnly?: boolean;
}
export const QUERY_TYPES: readonly QueryTypeOption[] = [
{
id: 'builder_query',
label: 'Builder Query',
supportsKeyScoping: true,
},
{
id: 'promql',
label: 'PromQL',
supportsKeyScoping: false,
metricsOnly: true,
},
{
id: 'clickhouse_sql',
label: 'ClickHouse SQL',
supportsKeyScoping: false,
},
] as const;
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
export const ANY_RESOURCE_VALUE = '*';
export interface SelectorDraft {
queryType: QueryTypeId;
value: string;
}
export interface ParsedSelector {
queryType?: QueryTypeId;
value: string;
}
export interface SelectorValidation {
message: string;
isError: boolean;
}

View File

@@ -0,0 +1,52 @@
.wizardDialog {
border-color: var(--l1-border);
--select-trigger-background-color: var(--l3-background);
--select-trigger-border-color: var(--l3-background);
--select-content-background: var(--l3-background);
--select-item-highlight-background: var(--l3-background-hover);
--select-trigger-outline-width: 1px;
--select-trigger-outline-offset: 0px;
--input-background: var(--l3-background);
--input-hover-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-disabled-background: var(--l3-background);
--input-border-color: var(--l3-border);
--input-hover-border-color: var(--l3-border);
--input-focus-border-color: var(--l3-border);
outline: none;
& > div {
background-color: var(--l2-background);
}
}
.wizardBody {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.wizardField {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.wizardValueRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.wizardValueInput {
flex: 1;
min-width: 0;
}
.selectContent {
z-index: 10;
background-color: var(--l3-background);
}

View File

@@ -0,0 +1,190 @@
import { Wand } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import {
ANY_RESOURCE_VALUE,
QUERY_TYPES,
SUPPORTED_GRANT_KEY,
} from './TelemetrySelectorWizard.constants';
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
import styles from './TelemetrySelectorWizard.module.scss';
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
interface TelemetrySelectorWizardProps {
onAdd: (selector: string) => void;
resource: AuthZResource;
testId: string;
}
function TelemetrySelectorWizard({
onAdd,
resource,
testId,
}: TelemetrySelectorWizardProps): JSX.Element {
const {
open,
queryType,
selectedQueryType,
value,
selector,
isAnyResource,
supportsKeyScoping,
validation,
canAdd,
handleOpenChange,
handleQueryTypeChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
handleAdd,
handleInputKeyDown,
} = useTelemetrySelectorWizard({ onAdd });
const trigger = (
<Button
variant="solid"
size="sm"
data-testid={`telemetry-wizard-trigger-${testId}`}
>
<Wand size={14} />
Wizard
</Button>
);
const footer = (
<>
<Button
variant="ghost"
color="secondary"
onClick={(): void => handleOpenChange(false)}
>
Cancel
</Button>
<Button
variant="solid"
onClick={handleAdd}
disabled={!canAdd}
data-testid={`wizard-add-btn-${testId}`}
>
Add Selector
</Button>
</>
);
return (
<DialogWrapper
open={open}
onOpenChange={handleOpenChange}
title="Selector Wizard"
width="wide"
testId={`telemetry-wizard-dialog-${testId}`}
trigger={trigger}
footer={footer}
className={styles.wizardDialog}
>
<div className={styles.wizardBody}>
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Query Type
</Typography>
<Select value={queryType} onChange={handleQueryTypeChange}>
<SelectTrigger data-testid={`wizard-query-type-select-${testId}`}>
<SelectValue>{selectedQueryType?.label}</SelectValue>
</SelectTrigger>
<SelectContent withPortal={false} className={styles.selectContent}>
{QUERY_TYPES.filter((queryTypeOption) =>
isQueryTypeAvailable(queryTypeOption, resource),
).map((queryTypeOption) => (
<SelectItem
key={queryTypeOption.id}
value={queryTypeOption.id}
testId={`wizard-query-type-option-${queryTypeOption.id}-${testId}`}
>
{queryTypeOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{supportsKeyScoping && (
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Key
</Typography>
<Input
value={SUPPORTED_GRANT_KEY}
readOnly
disabled
testId={`wizard-key-input-${testId}`}
/>
</div>
)}
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Value
</Typography>
<div className={styles.wizardValueRow}>
<Input
className={styles.wizardValueInput}
placeholder={
supportsKeyScoping
? 'Value or leave empty to allow every query'
: ANY_RESOURCE_VALUE
}
value={value}
disabled={!supportsKeyScoping}
onChange={handleValueChange}
onKeyDown={handleInputKeyDown}
testId={`wizard-value-input-${testId}`}
/>
<Checkbox
id={`wizard-any-resource-${testId}`}
value={isAnyResource}
disabled={!supportsKeyScoping}
onChange={(checked): void => handleAnyResourceChange(checked === true)}
testId={`wizard-any-resource-checkbox-${testId}`}
>
Any value
</Checkbox>
</div>
</div>
<div className={styles.wizardField}>
<Typography as="label" weight="medium">
Selector
</Typography>
<Input
value={selector}
onChange={handleSelectorChange}
onKeyDown={handleInputKeyDown}
testId={`wizard-selector-input-${testId}`}
/>
<Typography.Text
size="small"
color={validation.isError ? 'danger' : 'muted'}
testId={`wizard-selector-hint-${testId}`}
>
{validation.message}
</Typography.Text>
</div>
</div>
</DialogWrapper>
);
}
export default TelemetrySelectorWizard;

View File

@@ -0,0 +1,139 @@
import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
import {
ANY_RESOURCE_VALUE,
ParsedSelector,
QUERY_TYPES,
QueryTypeId,
QueryTypeOption,
SelectorDraft,
SelectorValidation,
SUPPORTED_GRANT_KEY,
} from './TelemetrySelectorWizard.constants';
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
'metrics',
'meter-metrics',
]);
export function getQueryTypeOption(
queryType: string,
): QueryTypeOption | undefined {
return QUERY_TYPES.find((option) => option.id === queryType);
}
export function isQueryTypeAvailable(
option: QueryTypeOption,
resource: AuthZResource,
): boolean {
return !option.metricsOnly || METRIC_RESOURCES.has(resource);
}
export function supportsKeyScoping(queryType: string): boolean {
return getQueryTypeOption(queryType)?.supportsKeyScoping ?? false;
}
export function isAnyResourceValue(value: string): boolean {
return value.trim() === ANY_RESOURCE_VALUE;
}
function splitSelector(selector: string): string[] {
const parts = selector.split('/');
if (parts.length <= 3) {
return parts;
}
return [parts[0], parts[1], parts.slice(2).join('/')];
}
export function buildSelector({ queryType, value }: SelectorDraft): string {
const trimmedValue = value.trim();
if (!supportsKeyScoping(queryType) || !trimmedValue) {
return `${queryType}/${ANY_RESOURCE_VALUE}`;
}
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
}
export function parseSelector(selector: string): ParsedSelector {
const parts = splitSelector(selector.trim());
return {
queryType: getQueryTypeOption(parts[0])?.id,
value: parts.length >= 3 ? parts[2] : '',
};
}
/**
* This does a basic validation, intentionally omitting deep validations since this is to be made at backend,
* so this will allow to produce invalid selectors, and the validation will be done after the user try to save
*/
export function validateSelector(selector: string): SelectorValidation {
const trimmed = selector.trim();
if (!trimmed) {
return { message: 'Enter a selector.', isError: true };
}
if (trimmed === ANY_RESOURCE_VALUE) {
return { message: 'Allow every query of every type.', isError: false };
}
const parts = splitSelector(trimmed);
const option = getQueryTypeOption(parts[0]);
if (!option) {
return {
message: `"${parts[0]}" is not a supported query type.`,
isError: true,
};
}
if (parts.length < 3) {
if (parts.length === 2 && parts[1] !== ANY_RESOURCE_VALUE) {
if (!option.supportsKeyScoping) {
return {
message: `This query type does not support key scoping. Use ${option.id}/*`,
isError: false, // intentionally not an error
};
}
return {
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow every "${option.label}" query.`,
isError: false,
};
}
const [, key, value] = parts;
if (value === ANY_RESOURCE_VALUE) {
return {
message: `Allow every ${key} for ${option.label} queries.`,
isError: false,
};
}
if (!option.supportsKeyScoping) {
return {
message: `This query type does not support key scoping. Use ${option.id}/*`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow ${key}=${value} for ${option.label} queries.`,
isError: false,
};
}
export function getDefaultSelector(queryType: QueryTypeId): string {
return buildSelector({ queryType, value: '' });
}

View File

@@ -76,9 +76,10 @@ function createGrantPermissionAsReadonly(
return {
label: 'readonly',
insertText: resources
.filter(
(r) => r.allowedVerbs.includes('read') || r.allowedVerbs.includes('list'),
)
.filter((r) => {
const verbs: readonly string[] = r.allowedVerbs;
return verbs.includes('read') || verbs.includes('list');
})
.flatMap((r) => {
const verbs = r.allowedVerbs.filter((v) => v === 'read' || v === 'list');
return verbs.map(
@@ -103,7 +104,12 @@ function buildResourceSnippets(): SnippetDef[] {
for (const resource of resources) {
const { kind, type, allowedVerbs } = resource;
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
// If only one verb is supported, no point on add grant all
// that will only add one permission at time, just leave the verb snippet
// and it will look cleaner
if (allowedVerbs.length > 1) {
snippets.push(createGrantAllPermissionSnippet(kind, allowedVerbs, type));
}
for (const verb of allowedVerbs) {
snippets.push(createGrantPermissionToVerbAndKind(kind, verb, type));

View File

@@ -0,0 +1,157 @@
import { useCallback, useMemo, useState } from 'react';
import {
ANY_RESOURCE_VALUE,
DEFAULT_QUERY_TYPE,
QueryTypeId,
QueryTypeOption,
SelectorValidation,
} from './TelemetrySelectorWizard.constants';
import {
buildSelector,
getDefaultSelector,
getQueryTypeOption,
isAnyResourceValue,
parseSelector,
validateSelector,
} from './TelemetrySelectorWizard.utils';
interface UseTelemetrySelectorWizardParams {
onAdd: (selector: string) => void;
}
interface UseTelemetrySelectorWizardResult {
open: boolean;
queryType: QueryTypeId;
selectedQueryType: QueryTypeOption | undefined;
value: string;
selector: string;
isAnyResource: boolean;
supportsKeyScoping: boolean;
validation: SelectorValidation;
canAdd: boolean;
handleOpenChange: (nextOpen: boolean) => void;
handleQueryTypeChange: (value: string | string[]) => void;
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAnyResourceChange: (checked: boolean) => void;
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAdd: () => void;
handleInputKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
}
function useTelemetrySelectorWizard({
onAdd,
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
const [open, setOpen] = useState(false);
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
const [value, setValue] = useState('');
const [selector, setSelector] = useState(() =>
getDefaultSelector(DEFAULT_QUERY_TYPE),
);
const selectedQueryType = useMemo(
() => getQueryTypeOption(queryType),
[queryType],
);
const supportsKeyScoping = selectedQueryType?.supportsKeyScoping ?? false;
const validation = useMemo(() => validateSelector(selector), [selector]);
const applyDraft = useCallback(
(nextQueryType: QueryTypeId, nextValue: string): void => {
setQueryType(nextQueryType);
setValue(nextValue);
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
},
[],
);
const handleQueryTypeChange = useCallback(
(next: string | string[]): void => {
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
applyDraft(selected, keepsValue ? value : '');
},
[applyDraft, value],
);
const handleValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
applyDraft(queryType, event.target.value);
},
[applyDraft, queryType],
);
const handleAnyResourceChange = useCallback(
(checked: boolean): void => {
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
},
[applyDraft, queryType],
);
const handleSelectorChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const nextSelector = event.target.value;
setSelector(nextSelector);
const parsed = parseSelector(nextSelector);
if (parsed.queryType) {
setQueryType(parsed.queryType);
}
setValue(parsed.value);
},
[],
);
const handleOpenChange = useCallback((nextOpen: boolean): void => {
setOpen(nextOpen);
if (!nextOpen) {
setQueryType(DEFAULT_QUERY_TYPE);
setValue('');
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
}
}, []);
const handleAdd = useCallback((): void => {
const trimmed = selector.trim();
if (validateSelector(trimmed).isError) {
return;
}
onAdd(trimmed);
handleOpenChange(false);
}, [selector, onAdd, handleOpenChange]);
const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
handleAdd();
}
},
[handleAdd],
);
return {
open,
queryType,
selectedQueryType,
value,
selector,
isAnyResource: isAnyResourceValue(value),
supportsKeyScoping,
validation,
canAdd: !validation.isError,
handleOpenChange,
handleQueryTypeChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
handleAdd,
handleInputKeyDown,
};
}
export default useTelemetrySelectorWizard;

View File

@@ -329,11 +329,15 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(3);
expect(result).toHaveLength(7);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});
@@ -414,11 +418,15 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(3);
expect(result).toHaveLength(7);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'role',
'serviceaccount',
'logs',
'traces',
'metrics',
'meter-metrics',
]);
});

View File

@@ -1,4 +1,12 @@
import { Bot, Key, Shield } from '@signozhq/icons';
import {
Bot,
ChartLine,
DraftingCompass,
Gauge,
Key,
Logs,
Shield,
} from '@signozhq/icons';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
import {
@@ -14,12 +22,15 @@ type IconComponent = typeof Shield;
const OBJECT_SCOPED_VERB_SET = new Set<string>(OBJECT_SCOPED_VERBS);
export type SelectorType = 'input' | 'telemetryBuilder';
export interface ResourcePanelConfig {
label: string;
description: string;
icon: IconComponent;
selectorPlaceholder: string;
docsAnchor: string;
selectorType?: SelectorType;
}
/**
@@ -50,6 +61,42 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type service account ID, separate multiple with comma or space',
docsAnchor: 'service-account',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',
icon: Logs,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'logs',
selectorType: 'telemetryBuilder',
},
traces: {
label: 'Traces',
description: 'Distributed tracing data collected across the workspace.',
icon: DraftingCompass,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'traces',
selectorType: 'telemetryBuilder',
},
metrics: {
label: 'Metrics',
description: 'Metric data collected across the workspace.',
icon: ChartLine,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'metrics',
selectorType: 'telemetryBuilder',
},
'meter-metrics': {
label: 'Meter Metrics',
description: 'Usage metering data for the workspace.',
icon: Gauge,
selectorPlaceholder:
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
docsAnchor: 'meter-metrics',
selectorType: 'telemetryBuilder',
},
};
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];

View File

@@ -108,6 +108,7 @@ export function getAppContextMockState(
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
hostsFetchError: undefined,

View File

@@ -144,6 +144,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
trialInfo,
isLoggedIn,
userPreferences,
isFetchingUserPreferences,
changelog,
toggleChangelogModal,
updateUserPreferenceInContext,
@@ -261,6 +262,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
// Compute initial pinned items and secondary menu items synchronously to avoid flash
const computedPinnedMenuItems = useMemo(() => {
// While loading, return empty to avoid flash
if (isFetchingUserPreferences) {
return [];
}
const navShortcutsPreference = userPreferences?.find(
(preference) => preference.name === USER_PREFERENCES.NAV_SHORTCUTS,
);
@@ -268,11 +274,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
| string[]
| undefined;
// If userPreferences not loaded yet, return empty to avoid showing defaults before preferences load
if (userPreferences === null) {
return [];
}
// If preference exists with non-empty array, use stored shortcuts
if (isArray(navShortcuts) && navShortcuts.length > 0) {
return navShortcuts
@@ -282,9 +283,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
.filter((item): item is SidebarItem => item !== undefined);
}
// No preference, or empty array → use defaults
// No preference, or empty array, or error loading → use defaults
return defaultMoreMenuItems.filter((item) => item.isPinned);
}, [userPreferences]);
}, [isFetchingUserPreferences, userPreferences]);
const computedSecondaryMenuItems = useMemo(() => {
const shouldShowIntegrationsValue =
@@ -322,12 +323,16 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
// Sync state only on initial load when userPreferences first becomes available
useEffect(() => {
// Only sync once: when userPreferences loads for the first time
if (!hasInitializedRef.current && userPreferences !== null) {
if (!hasInitializedRef.current && isFetchingUserPreferences === false) {
setPinnedMenuItems(computedPinnedMenuItems);
setSecondaryMenuItems(computedSecondaryMenuItems);
hasInitializedRef.current = true;
}
}, [computedPinnedMenuItems, computedSecondaryMenuItems, userPreferences]);
}, [
computedPinnedMenuItems,
computedSecondaryMenuItems,
isFetchingUserPreferences,
]);
const isChatSupportEnabled = featureFlags?.find(
(flag) => flag.name === FeatureKeys.CHAT_SUPPORT,

View File

@@ -1,62 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_COPIED_RESET_MS = 2000;
export interface UseCopyToClipboardOptions {
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
copiedResetMs?: number;
}
export type ID = number | string | null;
export interface UseCopyToClipboardReturn {
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
copyToClipboard: (text: string, id?: ID) => void;
/** True when something was just copied and still within the reset threshold. */
isCopied: boolean;
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
id: ID;
}
export function useCopyToClipboard(
options: UseCopyToClipboardOptions = {},
): UseCopyToClipboardReturn {
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
isCopied: false,
id: null,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const copyToClipboard = useCallback(
(text: string, id?: ID): void => {
// oxlint-disable-next-line signoz/no-navigator-clipboard
navigator.clipboard.writeText(text).then(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setState({ isCopied: true, id: id ?? null });
timeoutRef.current = setTimeout(() => {
setState({ isCopied: false, id: null });
timeoutRef.current = null;
}, copiedResetMs);
});
},
[copiedResetMs],
);
return {
copyToClipboard,
isCopied: state.isCopied,
id: state.id,
};
}

View File

@@ -19,7 +19,7 @@ describe('PermissionDeniedCallout', () => {
it('renders multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('serviceaccount', '*')),
buildPermission('update', buildObjectString('role', 'admin')),
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
];
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);

View File

@@ -20,7 +20,7 @@ describe('PermissionDeniedFullPage', () => {
it('renders with multiple denied permissions', () => {
const deniedPermissions = [
buildPermission('read', buildObjectString('role', 'admin')),
buildPermission('update', buildObjectString('role', 'admin')),
buildPermission('update', buildObjectString<'update'>('role', 'admin')),
];
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();

View File

@@ -35,6 +35,26 @@ export default {
'update',
],
},
{
kind: 'logs',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
{
kind: 'meter-metrics',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
{
kind: 'metrics',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
{
kind: 'traces',
type: 'telemetryresource',
allowedVerbs: ['read'],
},
],
relations: {
assignee: ['role'],
@@ -43,7 +63,7 @@ export default {
delete: ['metaresource', 'role', 'serviceaccount'],
detach: ['metaresource', 'role', 'serviceaccount'],
list: ['metaresource', 'role', 'serviceaccount'],
read: ['metaresource', 'role', 'serviceaccount'],
read: ['metaresource', 'role', 'serviceaccount', 'telemetryresource'],
update: ['metaresource', 'role', 'serviceaccount'],
},
},

View File

@@ -23,6 +23,40 @@ export function buildPermission<R extends AuthZRelation>(
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
}
/**
* Builds an object string for use with `buildPermission`.
*
* ## Type Inference Behavior
*
* TypeScript infers `R` from `resource`. If a resource belongs to multiple relations,
* the return type becomes a union of all matching `AuthZObject` types.
*
* Example: 'role' is valid for 'read', 'update', 'delete', 'assignee'.
* Without explicit generic, return type = `AuthZObject<'read' | 'update' | 'delete' | 'assignee'>`.
*
* ## When to specify explicit generic
*
* **Needed** when resource belongs to multiple relations AND you pass result to `buildPermission`
* with a relation that has FEWER valid resources than others:
*
* ```ts
* // ERROR: 'read' includes telemetry resources, 'update' does not
* buildPermission('update', buildObjectString('role', 'admin'))
*
* // OK: explicit generic narrows return type
* buildPermission('update', buildObjectString<'update'>('role', 'admin'))
* ```
*
* **Not needed** when:
* - Resource only belongs to one relation in the constraint
* - Using with 'read' relation (widest type, accepts all)
* - Storing in a variable typed as `BrandedPermission` (already opaque)
*
* ```ts
* // OK: 'read' is widest, accepts union return type
* buildPermission('read', buildObjectString('role', 'admin'))
* ```
*/
export function buildObjectString<
R extends 'delete' | 'read' | 'update' | 'assignee',
>(resource: ResourcesForRelation<R>, objectId: string): AuthZObject<R> {

View File

@@ -1,13 +1,11 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { Check, Copy } from '@signozhq/icons';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendPosition, LegendProps } from '../types';
@@ -33,7 +31,6 @@ export default function Legend({
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
@@ -57,17 +54,8 @@ export default function Legend({
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const handleCopyLegendItem = useCallback(
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
e.stopPropagation();
copyToClipboard(label, seriesIndex);
},
[copyToClipboard],
);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
const isCopied = copiedId === item.seriesIndex;
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
@@ -91,36 +79,18 @@ export default function Legend({
</div>
</TooltipSimple>
{showCopy && (
<TooltipSimple
title={isCopied ? 'Copied' : 'Copy'}
arrow
side="top"
disableHoverableContent
>
<Button
type="button"
size="icon"
variant="ghost"
color="secondary"
className="legend-copy-button"
onClick={(e): void =>
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
}
aria-label={`Copy ${item.label}`}
// data-testid (not testId): TooltipSimple's trigger injects
// data-testid:undefined via Radix Slot, and Button spreads
// incoming props after its own testId — so set it as a prop
// that wins the Slot merge and survives the spread.
data-testid="legend-copy"
>
{isCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position, showCopy],
[focusedSeriesIndex, position, showCopy],
);
const isEmptyState = useMemo(() => {

View File

@@ -1,11 +1,5 @@
import React from 'react';
import {
fireEvent,
render,
RenderResult,
screen,
within,
} from '@testing-library/react';
import { render, RenderResult, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { LegendItem } from 'lib/uPlotV2/config/types';
@@ -15,9 +9,6 @@ import { useLegendActions } from '../../hooks/useLegendActions';
import UPlotLegend from '../Legend/UPlotLegend';
import { LegendPosition } from '../types';
const mockWriteText = jest.fn().mockResolvedValue(undefined);
let clipboardSpy: jest.SpyInstance | undefined;
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
@@ -49,15 +40,6 @@ const mockUseLegendActions = useLegendActions as jest.MockedFunction<
>;
describe('UPlotLegend', () => {
beforeAll(() => {
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: () => Promise.resolve() },
writable: true,
configurable: true,
});
});
const baseLegendItemsMap = {
0: {
seriesIndex: 0,
@@ -89,11 +71,6 @@ describe('UPlotLegend', () => {
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
mockWriteText.mockClear();
clipboardSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockImplementation(mockWriteText);
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
@@ -110,7 +87,6 @@ describe('UPlotLegend', () => {
});
afterEach(() => {
clipboardSpy?.mockRestore();
jest.clearAllMocks();
});
@@ -237,47 +213,4 @@ describe('UPlotLegend', () => {
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
});
});
describe('copy action', () => {
it('copies the legend label to clipboard when copy button is clicked', () => {
renderLegend(LegendPosition.RIGHT);
const firstLegendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledTimes(1);
expect(mockWriteText).toHaveBeenCalledWith('A');
});
it('copies the correct label when copy is clicked on a different legend item', () => {
renderLegend(LegendPosition.RIGHT);
const thirdLegendItem = document.querySelector(
'[data-legend-item-id="2"]',
) as HTMLElement;
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledTimes(1);
expect(mockWriteText).toHaveBeenCalledWith('C');
});
it('does not call onLegendClick when copy button is clicked', () => {
renderLegend(LegendPosition.RIGHT);
const firstLegendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
fireEvent.click(copyButton);
expect(onLegendClick).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,97 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
}
/** The list spec a saved variable carries, whatever its plugin. */
function listSpec(m: VariableFormModel): {
allowMultiple?: boolean;
allowAllValue?: boolean;
} {
return formModelToDto(m).spec as {
allowMultiple?: boolean;
allowAllValue?: boolean;
};
}
describe('formModelToDto — the ALL flag needs multi-select', () => {
it('keeps ALL for a multi-select dynamic variable', () => {
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: true,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: true });
});
it('drops ALL for a single-select dynamic variable', () => {
// The API rejects allowAllValue without allowMultiple, and this used to be forced
// true for every dynamic variable — which blocked saving the whole dashboard.
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: false,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('drops ALL for a single-select query variable that still carries the flag', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: false,
showAllOption: true,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('respects the toggle on a multi-select query variable', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: true,
showAllOption: false,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: false });
});
it('round-trips a single-select dynamic variable unchanged', () => {
// What the dashboard in the report holds: saving it must not flip the flag.
const dto = {
kind: 'ListVariable',
spec: {
name: 'host.name',
display: { name: 'host.name', description: '' },
allowMultiple: false,
allowAllValue: false,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'host.name', signal: 'all' },
},
},
} as never;
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
allowMultiple: false,
allowAllValue: false,
});
});
});

View File

@@ -133,9 +133,14 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
// forced showALLOption true on save); other types respect the toggle. Either
// way it needs multi-select — ALL is a set of values, and the API rejects the
// flag without it, which used to make a single-select dynamic variable
// unsaveable and blocked every other edit to the dashboard with it.
allowAllValue:
model.multiSelect &&
(model.type === 'DYNAMIC' ? true : model.showAllOption),
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,

View File

@@ -13,6 +13,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
@@ -96,12 +97,10 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
variable={variable}
variables={variables}
selections={selection}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
// Until the seed commits a selection, stand in the same default it will
// commit, through the one resolver — an empty stand-in reads as "nothing
// selected" to a control that snapshots it on mount.
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VariableSelection } from '../selectionTypes';
import TextSelector from '../components/selectors/TextSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
function renderSelector(
selection: VariableSelection,
onChange = jest.fn(),
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
const { rerender } = render(
<TextSelector
selection={selection}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
);
return {
onChange,
rerender: (next): void =>
rerender(
<TextSelector
selection={next}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
),
};
}
function input(): HTMLInputElement {
return screen.getByTestId('variable-input-service') as HTMLInputElement;
}
describe('TextSelector', () => {
it('shows the value the seed commits after mount', () => {
// The bar mounts before the seed resolves the definition's default, so the first
// render sees nothing selected. The box must follow the value once it lands.
const { rerender } = renderSelector({ value: '', allSelected: false });
rerender({ value: 'flower', allSelected: false });
expect(input().value).toBe('flower');
});
it('follows a selection replaced from outside (share link, reset)', () => {
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
rerender({ value: 'rose', allSelected: false });
expect(input().value).toBe('rose');
});
it('keeps what the user types until they commit it', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
await user.clear(input());
await user.type(input(), 'tulip');
// Still local — a keystroke must not cascade to dependent panels.
expect(input().value).toBe('tulip');
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'tulip',
allSelected: false,
});
});
it('restores the default when emptied', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
await user.clear(input());
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'flower',
allSelected: false,
});
expect(input().value).toBe('flower');
});
});

View File

@@ -87,4 +87,99 @@ describe('ValueSelector', () => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
describe('an ALL selection whose options are still loading', () => {
it('reads ALL, not the "Select value" placeholder', () => {
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
// which carries no values at all) — the control must not read as unselected.
renderSelector({ value: null, allSelected: true }, [], true);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toHaveTextContent('ALL');
});
it('still shows concrete values while options load', () => {
renderSelector(
{ value: ['checkout-service-prod'], allSelected: false },
[],
true,
);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toBeNull();
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
expect(clearIcon()).toBeNull();
});
it('offers it once the list is open', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await openDropdown();
expect(clearIcon()).not.toBeNull();
});
it('offers no clear icon while every option is selected', async () => {
// ALL is every option, so there is nothing to clear — and the shared control
// refuses to empty an ALL selection, which would leave the icon inert.
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
await openDropdown();
expect(clearIcon()).toBeNull();
});
it('empties the list and commits nothing until it closes', async () => {
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="query"
multiSelect
showAllOption
selection={{ value: VALUES, allSelected: false }}
onChange={onChange}
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
await openDropdown();
await user.click(clearIcon() as Element);
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
0,
);
expect(onChange).not.toHaveBeenCalled();
// Closing fills in whatever the variable should hold.
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith({
value: [OPTIONS[0]],
allSelected: false,
});
});
});
});

View File

@@ -61,6 +61,47 @@ describe('useAutoSelect', () => {
expect(next).toBeUndefined();
});
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('still honours a configured default over ALL', () => {
const next = run(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),

View File

@@ -97,6 +97,125 @@ describe('useSeedVariableSelection', () => {
});
});
it('prefers the configured default over a stored empty text value', () => {
// Persisted from a session where the box was never filled — the default the
// definition carries must win, or the variable reads as unset forever.
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: '', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
});
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: [], allSelected: false } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
// Custom options need no request, so ALL is materialized here rather than left as
// a flag for the post-fetch reconcile to expand.
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b'],
allSelected: true,
});
});
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b,c',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b', 'c'],
allSelected: true,
});
});
it('drops values a custom variable no longer offers, at seed time', () => {
useDashboardStore.getState().setVariableValues('d1', {
env: { value: ['a', 'gone'], allSelected: false },
});
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a'],
allSelected: false,
});
});
it('keeps a stored ALL selection that has not materialized yet', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: null, allSelected: true } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: null,
allSelected: true,
});
});
it('does not rewrite the store when the effect re-runs with the same values', () => {
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
// re-running the seed. Writing an identical map would re-render every subscriber.
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
const { rerender } = renderHook(
({ dash }) => useSeedVariableSelection(dash),
{ initialProps: { dash: dashboard('d1', [variable]) } },
);
const afterSeed = useDashboardStore.getState().variableValues;
rerender({ dash: dashboard('d1', [{ ...variable }]) });
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),

View File

@@ -0,0 +1,152 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useVariableSelection } from '../hooks/useVariableSelection';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [null, jest.fn()],
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
const env = model({
name: 'env',
type: 'QUERY',
multiSelect: true,
queryValue: 'SELECT env',
});
const svc = model({
name: 'svc',
type: 'QUERY',
queryValue: 'SELECT svc WHERE env = $env',
});
const dashboard = {
id: 'd1',
spec: { variables: [env, svc] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
function svcCycleId(): number {
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
}
describe('useVariableSelection — setSelection', () => {
beforeEach(() => {
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
it('does not re-cascade when the picked value is the one already held', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
const afterFirstPick = svcCycleId();
// Same values, then the same set in a different order: neither is a change, so
// the dependent must not be re-fetched.
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
act(() => {
result.current.setSelection('env', {
value: ['b', 'a'],
allSelected: false,
});
});
expect(svcCycleId()).toBe(afterFirstPick);
});
it('ignores an auto-fill that the store already satisfies', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
// What a selector's first-render reconcile produces before the seed commits: a
// value the store then resolves to on its own.
await act(async () => {
result.current.autoSelect('env', { value: ['a'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(svcCycleId()).toBe(before);
});
it('still applies an auto-fill that changes the value', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
await act(async () => {
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
it('still cascades a genuine change', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
act(() => {
result.current.setSelection('env', {
value: ['a', 'c'],
allSelected: false,
});
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'c'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
});

View File

@@ -9,6 +9,7 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import { textDefault } from '../../utils/resolveVariableSelection';
import { computeVariableDependencies } from '../../utils/variableDependencies';
import TextSelector from '../selectors/TextSelector';
import VariableValueControl from '../selectors/VariableValueControl';
@@ -65,7 +66,7 @@ function VariableSelector({
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={variable.textValue}
defaultValue={textDefault(variable)}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
@@ -31,6 +31,17 @@ function TextSelector({
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
// The committed value can land after this input mounts — the seed resolves the
// definition's default one tick later, and a share link or a reset can replace it
// at any point. Without following it the box would keep showing its first render
// (empty, since nothing is seeded yet) until the user focused and blurred it.
// Typing never touches the selection, so an edit in progress cannot be interrupted.
useEffect(() => {
setValue(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
}, [selection.value, defaultValue]);
const commit = useCallback(
(next: string): void => {
void logEvent(

View File

@@ -54,11 +54,26 @@ function ValueSelector({
[selection, options],
);
// That "all" path needs the options, so an ALL selection whose options have not
// arrived yet has nothing to render and the control would read "Select value"
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
// selection is known, only its options are pending. Display only, so it can never
// be committed as a value.
const isAllPendingOptions = selection.allSelected && options.length === 0;
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
// ALL is every option, so there is nothing to clear — and the shared control refuses
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
// in the list is the way out of it.
const draftIsAll =
showAllOption &&
options.length > 0 &&
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
@@ -94,8 +109,11 @@ function ValueSelector({
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
allowClear
placeholder="Select value"
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
// not visible.
allowClear={isOpen && !draftIsAll}
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
@@ -129,12 +147,10 @@ function ValueSelector({
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
variableType,
});
// Empties the list, committing nothing. Closing resolves an empty draft
// to whatever the variable should hold — its configured default, else ALL
// where it offers one, else the first option.
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
/>
);

View File

@@ -6,7 +6,13 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
areSelectionsEqual,
reconcileWithOptions,
resolveDefaultSelection,
} from '../utils/resolveVariableSelection';
import { hasUsableValue } from '../utils/selectionUtils';
import type {
SelectedVariableValue,
VariableSelection,
@@ -18,6 +24,44 @@ import {
} from '../utils/variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
function isStoredSelectionSet(
stored: VariableSelection,
model: VariableFormModel,
): boolean {
return !!stored.allSelected || hasUsableValue(stored, model.type);
}
/** Whether the seeded map matches, entry for entry, what the store already holds. */
function isSameSelection(
seeded: VariableSelectionMap,
current: VariableSelectionMap,
): boolean {
const names = Object.keys(seeded);
return (
names.length === Object.keys(current).length &&
names.every(
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
)
);
}
/**
* A seeded value taken as far as it can go: for a variable whose options need no
* request, resolve against them now — ALL becomes the concrete array, values the list
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
* once the options arrive.
*/
function resolveAgainstKnownOptions(
value: VariableSelection,
model: VariableFormModel,
): VariableSelection {
const options = knownVariableOptions(model);
if (options.length === 0) {
return value;
}
return reconcileWithOptions(model, value, options) ?? value;
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -70,22 +114,31 @@ export function useSeedVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
const seed = (value: VariableSelection): void => {
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
};
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seeded[variable.name] =
seed(
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
: fromUrl,
);
} else if (stored && isStoredSelectionSet(stored, variable)) {
seed(stored);
} else {
seeded[variable.name] = resolveDefaultSelection(variable);
seed(resolveDefaultSelection(variable));
}
});
setVariableValues(dashboardId, seeded);
// This runs again whenever the spec's variable array changes identity — a refetch
// or any spec edit — and writing an identical map would re-render every selection
// subscriber for nothing.
if (!isSameSelection(seeded, selection)) {
setVariableValues(dashboardId, seeded);
}
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never

View File

@@ -1,14 +1,11 @@
import { useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import {
sortValuesByOrder,
VARIABLE_TYPE_EVENT_LABEL,
} from '../../DashboardSettings/Variables/variableFormModel';
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
useFetchedVariableOptions,
type VariableOptions,
@@ -30,14 +27,12 @@ export function useVariableOptions(
): VariableOptions {
const fetched = useFetchedVariableOptions(variable, variables, selections);
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
// refetch hands back an equal-but-new model, and a new options array would re-fire
// the post-fetch reconcile for nothing.
const customOptions = useMemo(
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
() => knownVariableOptions(variable),
// eslint-disable-next-line react-hooks/exhaustive-deps
[variable.type, variable.customValue, variable.sort],
);

View File

@@ -12,6 +12,7 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
import { useSeedVariableSelection } from './useSeedVariableSelection';
/**
@@ -103,6 +104,10 @@ export function useVariableSelection(
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
const current = selectionRef.current[name];
if (current && areSelectionsEqual(next, current)) {
return;
}
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
},
@@ -124,8 +129,19 @@ export function useVariableSelection(
if (names.length === 0 || !dashboardId) {
return;
}
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
enqueueDescendantsBatch(names);
// A fill can arrive already satisfied: a selector reconciles against the options
// on its first render, before the seed has committed, and the seed then resolves
// to the same value. Refresh only what actually moved, so a settled load ends
// without a write or a dependent refetch.
const current = selectionRef.current;
const changed = names.filter(
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
);
if (changed.length === 0) {
return;
}
setVariableValues(dashboardId, { ...current, ...fills });
enqueueDescendantsBatch(changed);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(

View File

@@ -0,0 +1,23 @@
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
/**
* The options of a variable whose list needs no request — a CUSTOM variable's comma
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
* TEXT, which has none.
*
* Knowing them up front lets the seed resolve such a variable's value completely
* (materializing ALL, dropping values the list no longer offers) instead of leaving
* that to the post-fetch reconcile, which would cost a second store write and a
* refetch of everything downstream.
*/
export function knownVariableOptions(model: VariableFormModel): string[] {
if (model.type !== 'CUSTOM') {
return [];
}
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
String,
);
}

View File

@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
function textDefault(model: VariableFormModel): string {
export function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
@@ -53,15 +53,31 @@ function isAllDefault(
);
}
/** The configured default (or first option) as a fresh selection. */
/**
* The default selection for a variable with nothing usable selected: its configured
* default, else ALL for an ALL-enabled multi-select, else the first option.
*/
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
const initial = fallback && options.includes(fallback) ? fallback : options[0];
if (fallback && options.includes(fallback)) {
return {
value: model.multiSelect ? [fallback] : fallback,
allSelected: false,
};
}
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
// to an arbitrary first option — the same default the seed applies (keep in sync
// with resolveDefaultSelection).
if (model.multiSelect && model.showAllOption) {
return materializeAll(model, options, null) ?? ALL_SELECTION;
}
return {
value: model.multiSelect ? [initial] : initial,
value: model.multiSelect ? [options[0]] : options[0],
allSelected: false,
};
}

View File

@@ -1,10 +1,10 @@
import { CSSProperties, useCallback } from 'react';
import { CSSProperties, type MouseEvent, useCallback } from 'react';
import { Check, Copy } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
import styles from './CopyButton.module.scss';
import { useCopyButton } from './useCopyButton';
export interface CopyButtonProps {
/** Text written to the clipboard on click. */
@@ -30,11 +30,15 @@ function CopyButton({
className,
testId,
}: CopyButtonProps): JSX.Element {
const { copyToClipboard, isCopied } = useCopyToClipboard();
const { copyToClipboard, isCopied } = useCopyButton();
const handleClick = useCallback((): void => {
copyToClipboard(value);
}, [copyToClipboard, value]);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
copyToClipboard(value);
},
[copyToClipboard, value],
);
const stackStyle: CSSProperties = { width: size, height: size };

View File

@@ -0,0 +1,65 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CopyButton from '../CopyButton';
const mockCopy = jest.fn();
// Exercise the real useCopyButton — stub only react-use's underlying copy so the
// click doesn't hit copy-to-clipboard's jsdom fallback (window.prompt).
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
describe('CopyButton', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.useFakeTimers();
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
mockCopy.mockClear();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('copies its value on click', async () => {
render(<CopyButton value="hello" testId="copy" />);
await user.click(screen.getByTestId('copy'));
expect(mockCopy).toHaveBeenCalledWith('hello');
});
it('does not trigger parent click handlers (stops propagation)', async () => {
const onParentClick = jest.fn();
render(
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div onClick={onParentClick}>
<CopyButton value="x" testId="copy" />
</div>,
);
await user.click(screen.getByTestId('copy'));
expect(onParentClick).not.toHaveBeenCalled();
});
it('shows the copied state after clicking and reverts after the reset window', async () => {
const { container } = render(<CopyButton value="hello" testId="copy" />);
const iconStack = container.querySelector('[data-copied]') as HTMLElement;
expect(iconStack).toHaveAttribute('data-copied', 'false');
await user.click(screen.getByTestId('copy'));
expect(iconStack).toHaveAttribute('data-copied', 'true');
act(() => {
jest.runOnlyPendingTimers();
});
expect(iconStack).toHaveAttribute('data-copied', 'false');
});
});

View File

@@ -0,0 +1,2 @@
/** How long (ms) CopyButton stays in the "copied" state before reverting. */
export const COPIED_RESET_MS = 1000;

View File

@@ -0,0 +1,44 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { COPIED_RESET_MS } from './constants';
export interface UseCopyButtonReturn {
copyToClipboard: (text: string) => void;
isCopied: boolean;
}
export function useCopyButton(): UseCopyButtonReturn {
const [, copy] = useCopyToClipboard();
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const copyToClipboard = useCallback(
(text: string): void => {
copy(text);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsCopied(true);
timeoutRef.current = setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, COPIED_RESET_MS);
},
[copy],
);
return {
copyToClipboard,
isCopied,
};
}

View File

@@ -434,6 +434,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isFetchingHosts,
isFetchingFeatureFlags,
isFetchingOrgPreferences,
isFetchingUserPreferences,
userFetchError,
activeLicenseFetchError,
hostsFetchError,
@@ -464,6 +465,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isFetchingHosts,
isFetchingFeatureFlags,
isFetchingOrgPreferences,
isFetchingUserPreferences,
isFetchingUser,
isLoggedIn,
hostsData,

View File

@@ -27,6 +27,7 @@ export interface IAppContext {
isFetchingHosts: boolean;
isFetchingFeatureFlags: boolean;
isFetchingOrgPreferences: boolean;
isFetchingUserPreferences: boolean;
userFetchError: unknown;
activeLicenseFetchError: APIError | null;
hostsFetchError: unknown;

View File

@@ -242,6 +242,7 @@ export function getAppContextMock(
userPreferences: [],
updateUserPreferenceInContext: jest.fn(),
isFetchingOrgPreferences: false,
isFetchingUserPreferences: false,
orgPreferencesFetchError: null,
isLoggedIn: true,
isPreflightLoading: false,

View File

@@ -23,6 +23,11 @@ export type ComponentTypes =
| 'add_panel_locked_dashboard'
| 'manage_llm_pricing';
/**
* @deprecated Before adding a new value here, check if what you want to add permission is supported by authz.
* If so, read AuthZ Guidelines on how to add permission check via AuthZ.
* If not, you can keep adding to this record.
*/
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
invite_members: ['ADMIN'],
@@ -46,11 +51,16 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
manage_llm_pricing: ['ADMIN'],
};
/**
* @deprecated You can still add new permissions/routes here but be aware if this page/module supports authz.
* If so, also implement the correct authz checks in the page itself, and here you can add ADMIN/EDITOR/VIEWER,
* and also update/include the route at {@link routeWithInitialAuthZSupport}
*/
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERTS_NEW: ['ADMIN', 'EDITOR'],
ORG_SETTINGS: ['ADMIN'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -75,13 +85,15 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE_DETAIL_OLD: ['ADMIN', 'EDITOR', 'VIEWER'],
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
// Every role must be able to land here - a role missing from this list is
// redirected to /un-authorized and then redirected off it again, looping.
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS', 'AUTHOR'],
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -95,12 +107,12 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_WITH_CLOUD: ['ADMIN', 'EDITOR'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_CREATE: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_EDIT: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -141,3 +153,34 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],
};
/**
* Any route that will start be supported under AuthZ should be added here.
* This will help correctly identify when fallback to show the page
* or just return unauthorized.
*
* This prevents us from adding `ANONYMOUS` on the `routePermission`
*/
export const routeWithInitialAuthZSupport = {
MY_SETTINGS: true,
SETTINGS: true,
TRACES_EXPLORER: true,
TRACE: true,
TRACE_DETAIL: true,
TRACE_DETAIL_OLD: true,
LOGS: true,
LOGS_EXPLORER: true,
LIVE_LOGS: true,
ROLES_SETTINGS: true,
ROLE_CREATE: true,
ROLE_DETAILS: true,
ROLE_EDIT: true,
SERVICE_ACCOUNTS_SETTINGS: true,
SUPPORT: true,
OLD_LOGS_EXPLORER: true,
METRICS_EXPLORER: true,
METRICS_EXPLORER_EXPLORER: true,
METRICS_EXPLORER_VOLUME_CONTROL: true,
METER_EXPLORER: true,
METER: true,
} as const satisfies Partial<Record<keyof typeof ROUTES, true>>;

View File

@@ -143,6 +143,12 @@ export default defineConfig(({ mode }): UserConfig => {
plugins,
resolve: {
alias: {
// @grafana/data imports bare CJS `lodash`, whose UMD footer checks for an
// AMD loader before assigning module.exports. Any third-party script that
// defines window.define.amd first leaves the bundled namespace empty, so
// every lodash method reached through it is undefined at runtime. lodash-es
// is the same version, real ESM, and tree-shakes.
lodash: 'lodash-es',
'@': resolve(__dirname, './src'),
utils: resolve(__dirname, './src/utils'),
types: resolve(__dirname, './src/types'),

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